release-2.9.0 (#1532)

* fix(macos): skip single-instance lock in Mac App Store builds (#1454)

fix(macos): skip single-instance lock in Mac App Store builds

* Fix/backend optional sharp (#1455)

Fix/backend optional sharp

* feat: issue #1218 (#1218)

https://github.com/Termix-SSH/Support/issues/1218

* feat: Add option to use saved global custom theme under Connection Defaults (#1209)

https://github.com/Termix-SSH/Support/issues/1209

* feat: Add Fleet Sharing functionality to the UI (#1228)

https://github.com/Termix-SSH/Support/issues/1228

* feat: Add a list view to the Docker management page (#1237)

https://github.com/Termix-SSH/Support/issues/1237

* feat: issue #1264 (#1264)

https://github.com/Termix-SSH/Support/issues/1264

* feat: 2FA With FortiToken (#1288)

https://github.com/Termix-SSH/Support/issues/1288

* feat: Allow to disable showing paths to folders (#1274)

https://github.com/Termix-SSH/Support/issues/1274

* feat: Sync Network Graph between Desktop and Remote Server (#1245)

https://github.com/Termix-SSH/Support/issues/1245

* feat: Version Number on offline servers (#1291)

https://github.com/Termix-SSH/Support/issues/1291

* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)

https://github.com/Termix-SSH/Support/issues/1302

* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)

https://github.com/Termix-SSH/Support/issues/1289

* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)

https://github.com/Termix-SSH/Support/issues/1303

* fix: I can't send a file on its own. (#1304)

https://github.com/Termix-SSH/Support/issues/1304

* fix: sudo password not autofilling (#1248)

https://github.com/Termix-SSH/Support/issues/1248

* fix: node-MainThread (#1300)

https://github.com/Termix-SSH/Support/issues/1300

* fix: Tunnel authentication with shared credentials (#1295)

https://github.com/Termix-SSH/Support/issues/1295

* fix: host key updates not syncing, metrics 404 race on first connect

Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.

* chore: increment ver

* feat: add category and icon fields to plugin manifest schema

* ci(deps): bump the github-actions group with 2 updates (#1458)

Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).


Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)

Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: crowdin/github-action
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)

Bumps the dev-patch-updates group with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |


Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)

Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)

Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)

Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)

Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)

Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)

Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)

Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)

Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.11.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
  dependency-version: 10.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
  dependency-version: 1.15.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: cytoscape
  dependency-version: 3.34.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)

Bumps the prod-patch-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |


Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)

Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)

Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)

Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)

Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jose
  dependency-version: 6.2.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jszip
  dependency-version: 3.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: socks
  dependency-version: 2.8.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: undici
  dependency-version: 8.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: add plugin worker protocol, bootstrap and plugin_storage table

Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.

* feat: add allowlisted host view for plugins

An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.

* feat: add plugin loader with manifest validation and crash restart

Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.

The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.

loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.

* feat: route internal events through a shared plugin event bus

Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.

Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.

* feat: add plugin ctx broker with permission gate and audit

Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.

Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.

fix: stop plugin workers seeing plaintext credentials in error messages

A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:

  Authentication failed for root using password <SECRET>

where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.

Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.

Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.

* feat: dispatch plugin HTTP routes and wire the runtime into startup

ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.

Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.

fix: correct the nginx plugin-api location regex

The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.

* fix: update stale SFTP transfer test for single file destination paths

The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.

* feat: ship the ssh terminal as a first-party in-process plugin

Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.

* feat: ship docker as a first-party in-process plugin

Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.

* feat: ship host metrics as a first-party in-process plugin

* fix: harden plugin auth and add per-request identity for ctx calls

Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.

* feat: ship the AI assistant as a first-party in-process plugin

Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.

* fix: remove compiled plugin JS left behind in plugins/ after build

copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.

* feat: add a permission-gated backend service registry for plugins

* feat: add a permission-gated UI action registry and action slots for plugins

Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.

* feat: add cross-plugin secret references gated by the provider's RBAC permission

* feat: move proxmox discovery/import/sync into a first-party plugin

* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin

Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.

* feat: wire remote-desktop plugin into core and rename i18n keys

Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.

* feat: move fleets into a first-party plugin

* feat: move automations into a first-party plugin

* feat: remove legacy alert rules, superseded by automations

Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.

* feat: move network topology into a first-party plugin

* feat: move tailscale device discovery and settings into a plugin

Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.

* feat: move workspaces into a first-party plugin

* feat: move web endpoint into a first-party plugin

* feat: collapse plugin runtime to one tier behind a plugin SDK

* feat: make each plugin an npm workspace built by a termix-plugin CLI

* feat: give plugins their own tables, migrations and sync entities

* feat: serve plugin http and websockets from one mount instead of ports

* feat: namespace plugin permissions and keep them valid when a plugin is off

* feat: give plugins their own settings and one settings screen

* feat: load plugin frontends at runtime and remove plugin ids from the shell

* fix: mock the ai status fetch in its activate test

* feat: add auth extension points and one ssh connect pipeline

* feat: rebuild workspaces as the reference plugin

* fix: gate second factors after SSO/LDAP behind an admin setting

* feat: convert network-topology to a full plugin (B1)

* feat: move snippets backend and tables into a plugin (B2)

* feat: point core frontend snippets pickers at the plugin (B2)

* feat: add the snippets plugin (B2)

* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)

* feat: finish the tailscale plugin, including SSH auth (B3)

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* chore: update proxmox and host-metrics locale files for B5

* chore: regenerate drizzle migration journals for B5

* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox

* feat: move proxmox node stats and collectors into the proxmox plugin

* feat: drop proxmox host columns, use plugin settings instead

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* feat: convert file manager into a plugin

* feat: convert ssh tunnels into a plugin

* feat: finish the web-endpoint plugin, add ctx.desktop (B8)

* feat: move the terminal fully into the ssh-terminal plugin (B9)

* feat: convert tmux monitoring into a plugin (B10)

* feat: convert serial console into the serial plugin

* feat: move session sharing and collab rooms into a plugin (B12)

* feat: turn session recording into a plugin (B13)

* feat: finish the remote-desktop plugin (B14)

* feat: finish the host-metrics plugin, move host status into core (B16)

* feat: finish the docker plugin (B15)

* feat: finish the automations plugin (B17)

* feat: finish the ai plugin (B18)

* feat: finish the homepage plugin, move service links and canvas out of core (B19)

* feat: convert wake-on-lan into a plugin (B20)

* feat: convert secret-sources into a plugin (B20)

* feat: move totp and passkeys into plugins (C1)

* feat: move sso and ldap logins into plugins (C2)

* feat: move opkssh and warpgate into plugins (C3)

* feat: move step-ca into a plugin (C4)

* feat: move vault ssh signing into a plugin (C5)

* feat: move termix identity into a plugin (C6)

* feat: move acme certificate automation into a plugin (C7)

* fix: finish 2.9.0 plugin moves and get every check green

* chore: lock the plugin boundary and remove 2.9.0 leftovers

* fix: harden the plugin runtime after the 2.9.0 security review

* feat: add plugin packing, signing, pinned trust and tmxplug bundling

* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught

* fix: give bundled plugin backends a real require for CJS deps

* feat: improve UI loading time and jitter

* feat: show credential sharing inline in the sidebar like host sharing

* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning

* feat: unify alerts into one plugin, channels, rules and inbox

* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

* fix: set blacksmith builder cache-key to stop broken gha cache fallback

* fix: link plugin-sdk workspace before npm ci in docker deps stage

* fix: link every plugin workspace before npm ci in docker deps stage

* fix(host-metrics): exclude image and firmware filesystems (#1469)

* fix(host-metrics): exclude image and firmware filesystems

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(hosts): normalize imported jump host references (#1470)

* fix(hosts): normalize imported jump host references

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent (#1476)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent

* fix(dashboard): reuse translated host status labels (#1475)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(dashboard): reuse translated host status labels

* docs(api): clarify local and remote version fields (#1474)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* docs(api): clarify local and remote version fields

* fix(file-manager): preserve selected backgrounds on hover (#1473)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): preserve selection feedback on hover

* test(ui): await dialog focus cleanup before teardown

* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): focus file search with Ctrl or Cmd F

* fix: keep rounded-full circular in production builds

* fix(file-manager): cancel uploads and stop remote writes (#1477)

* fix(file-manager): cancel uploads and stop remote writes

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): apply compact density to directory tree (#1478)

* fix(file-manager): apply compact density to directory tree

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): preserve font sizes in compact mode (#1480)

* fix(file-manager): preserve font sizes in compact mode

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): prioritize file names in narrow lists (#1483)

* fix(file-manager): prioritize file names in narrow lists

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(remote-desktop): probe the saved host guacd endpoint (#1481)

* fix(remote-desktop): probe the saved host guacd endpoint

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* fix(homepage): check allowlisted private service reachability (#1479)

* fix(homepage): check allowlisted private service reachability

* test(css): exercise the configured PostCSS plugin chain

* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)

* fix(remote-desktop): read clipboard only on explicit paste

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* chore: simplify plugin readmes, changelogs and manifest descriptions

* fix: keep plugins loaded after signing in from the login screen

* fix(file-manager): accept OS file drops over existing rows (#1484)

* fix(file-manager): accept OS file drops over existing rows

* test(file-manager): target the file label in drop tests

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(file-manager): pipeline SFTP download reads (#1485)

* feat: add plugin sdk build to beta release

* fix: make link-store secret check immune to random base64 matches

* fix(ldap): preserve the directory-provided bind DN (#1486)

* fix: compute beta changes across dev branches and move the beta tag

* chore: untrack plugin sdk ARCHITECTURE.md

* chore: remove references to plugin sdk ARCHITECTURE.md

* feat: merge host editor terminal tabs into one owned by ssh-terminal

* feat: add descriptions to plugin host enable toggles

* fix(file-manager): show download progress in file viewers (#1487)

* fix(electron): signal backend readiness over IPC (#1488)

* fix(split-screen): allow layout creation without randomUUID (#1490)

* fix(split-screen): allow layout creation without randomUUID

* test(credentials): avoid flaky Ed25519 key generation

* fix(sidebar): remeasure visible rows after density changes (#1489)

* fix(sidebar): remeasure visible rows after density changes

* test(credentials): avoid flaky Ed25519 key generation

* feat(split-screen): redesign split screen with free-form panes and pane picker

* feat(sidebar): add host click behavior setting, default to always opening a new tab

* feat(snippets): redesign panel to match the host list

* fix(session-recording): stop session logs panel from refetching in a loop

* feat(snippets): bring back sharing, target hosts and drag reorder

* feat(ssh-terminal): add toolbar position, start state, display and fade host settings

* feat(ssh-terminal): add host setting to turn off password prompt auto-fill

* fix(ai): stop streams getting cut off and keep tool steps across messages

* feat: improve quick connect system using plugin api

* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport

* feat: move terminal settings, look and ssh tools into ssh-terminal plugin

* feat: move snippets, keybinding actions and macros out of core into plugins

* feat: store rdp, vnc and telnet logins in a generic per-protocol table

* feat: type the plugin host record, move plugin strings out of core and add lint checks

* feat: replace core homepage widget registry with generic extension points

* feat: plugins only see their own host settings in the browser

* feat: harden plugin api for separate repos, signed updates and generic external login

* fix: keep plugin css below core utilities and version plugin assets by their css

* fix: load and save plugin host settings in the editor and tighten settings row spacing

* feat: redesign user host defaults system

* fix(remote-desktop): release held keys on focus loss (#1498)

* fix(session-sharing): replace local presentation after takeover (#1500)

* fix(file-manager): refresh disk usage with directory listing (#1503)

* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)

* feat(remote-desktop): expose display zoom controls for RDP (#1514)

* fix(file-manager): restore text selection and copying in Markdown previews (#1518)

* fix(sidebar): keep compact host addresses visible (#1524)

* fix(hosts): preserve plugin settings when reopening the editor (#1499)

* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)

* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)

* fix(session-sharing): show participant presence in member terminals (#1504)

* fix(ai): sync provider configuration and keys across linked devices (#1506)

* feat(ssh-terminal): add a duplicate tab action (#1510)

* feat(file-manager): allow disabling move-to-trash confirmation (#1511)

* feat(file-manager): default to the remote login directory (#1512)

* feat(homepage): synchronize canvas layouts across devices (#1515)

* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)

* feat(tmux-monitor): add a unified multi-host view (#1520)

* fix(electron): allow explicit quit with active connections (#1522)

* feat: reconnect all disconnected terminal tabs (#1523)

* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)

* fix(database): preserve RDP domain during startup backfill (#1497)

* feat(sidebar): add keyboard type-ahead search for hosts (#1516)

* feat(automations): add per-host maintenance mode and recurring schedules (#1521)

* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)

* fix(session-sharing): use the linked server for desktop meetings and links (#1507)

* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)

* fix: type errors and lint failures from merged plugin prs

* feat(installer): make the Windows desktop shortcut optional (#1517)

* fix: ship the plugin sdk and serialport in desktop builds

* fix: prefer ipv4 when resolving ssh host names (#1365)

* fix: readable terminal selection with the light theme (#1319)

* fix: build desktop session share links on the linked server (#1350)

* fix: show meeting guests to members and hide the roster from guests (#1353)

* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)

* fix: show disk usage for the mount being browsed (#1347)

* fix: load the linux tray icon reliably and log tray failures (#1332)

* fix: cover the gnome close path in the desktop quit test (#1332)

* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)

* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

* chore: lint, format, and bump version to 2.9.0

* chore: skip crowdin pre-translate in release for now

* chore: sync Crowdin translations for 2.9.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-01 16:20:16 -05:00
1 parent 49fa488093
commit 3643e7af97
4405 files changed
+1341026 -289722

No files matched your search

@@ -0,0 +1,483 @@
import type { Express } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { execChannel, type SSHSession } from "./session.js";
type FileActionRoutesDeps = {
ctx: PluginContext;
sshSessions: Record<string, SSHSession>;
scheduleSessionCleanup: (sessionId: string) => void;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
export function registerFileActionRoutes(
app: Express,
{
ctx,
sshSessions,
scheduleSessionCleanup,
verifySessionOwnership,
}: FileActionRoutesDeps,
): void {
/**
* @openapi
* /plugin-api/file-manager/copyItem:
* post:
* summary: Copy a file or directory
* description: Copies a file or directory on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* sourcePath:
* type: string
* targetDir:
* type: string
* hostId:
* type: integer
* userId:
* type: string
* responses:
* 200:
* description: Item copied successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 500:
* description: Failed to copy item.
*/
app.post("/copyItem", async (req, res) => {
const { sessionId, sourcePath, targetDir, hostId } = req.body;
const userId = ctx.currentActor()!;
if (!sessionId || !sourcePath || !targetDir) {
return res.status(400).json({ error: "Missing required parameters" });
}
const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) {
return res
.status(400)
.json({ error: "SSH session not found or not connected" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const sourceName = sourcePath.split("/").pop() || "copied_item";
const timestamp = Date.now().toString().slice(-8);
const uniqueName = `${sourceName}_copy_${timestamp}`;
const targetPath = `${targetDir}/${uniqueName}`;
const escapedSource = sourcePath.replace(/'/g, "'\"'\"'");
const escapedTarget = targetPath.replace(/'/g, "'\"'\"'");
const copyCommand = `cp '${escapedSource}' '${escapedTarget}' && echo "COPY_SUCCESS"`;
const commandTimeout = setTimeout(() => {
ctx.log.error(
`Copy command timed out after 60 seconds: ${sourcePath} -> ${targetPath} (${copyCommand})`,
);
if (!res.headersSent) {
res.status(500).json({
error: "Copy operation timed out",
toast: {
type: "error",
message:
"Copy operation timed out. SSH connection may be unstable.",
},
});
}
}, 60000);
execChannel(sshConn, copyCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
ctx.log.error("SSH copyItem error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let errorData = "";
let stdoutData = "";
stream.on("data", (data: Buffer) => {
const output = data.toString();
stdoutData += output;
stream.stderr.on("data", (data: Buffer) => {
const output = data.toString();
errorData += output;
});
stream.on("close", (code) => {
clearTimeout(commandTimeout);
if (code !== 0) {
const fullErrorInfo =
errorData || stdoutData || "No error message available";
ctx.log.error(
`SSH copyItem command failed with code ${code}: ${sourcePath} -> ${targetPath} (${copyCommand}) ${fullErrorInfo}`,
);
if (!res.headersSent) {
return res.status(500).json({
error: `Copy failed: ${fullErrorInfo}`,
toast: {
type: "error",
message: `Copy failed: ${fullErrorInfo}`,
},
debug: {
sourcePath,
targetPath,
exitCode: code,
command: copyCommand,
},
});
}
return;
}
const copySuccessful =
stdoutData.includes("COPY_SUCCESS") || code === 0;
if (copySuccessful) {
ctx.log.info(
`Item copied successfully: ${sourcePath} -> ${targetPath} (sessionId=${sessionId}, hostId=${hostId})`,
);
if (!res.headersSent) {
res.json({
message: "Item copied successfully",
sourcePath,
targetPath,
uniqueName,
toast: {
type: "success",
message: `Successfully copied to: ${uniqueName}`,
},
});
}
} else {
ctx.log.warn(
`Copy completed but without success confirmation: ${sourcePath} -> ${targetPath} (sessionId=${sessionId}, code=${code}, stdout=${stdoutData.substring(0, 200)})`,
);
if (!res.headersSent) {
res.json({
message: "Copy may have completed",
sourcePath,
targetPath,
uniqueName,
toast: {
type: "warning",
message: `Copy completed but verification uncertain for: ${uniqueName}`,
},
});
}
}
});
stream.on("error", (streamErr) => {
clearTimeout(commandTimeout);
ctx.log.error("SSH copyItem stream error:", streamErr);
if (!res.headersSent) {
res
.status(500)
.json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
});
/**
* @openapi
* /plugin-api/file-manager/executeFile:
* post:
* summary: Execute a file
* description: Executes a file on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* filePath:
* type: string
* responses:
* 200:
* description: File execution result.
* 400:
* description: Missing required parameters or SSH connection not available.
* 500:
* description: Failed to execute file.
*/
app.post("/executeFile", async (req, res) => {
const { sessionId, filePath } = req.body;
const sshConn = sshSessions[sessionId];
const userId = ctx.currentActor()!;
if (!sshConn || !sshConn.isConnected) {
ctx.log.error(
`SSH connection not found or not connected for executeFile: ${sessionId} (hasConnection=${!!sshConn}, isConnected=${sshConn?.isConnected})`,
);
return res.status(400).json({ error: "SSH connection not available" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath) {
return res.status(400).json({ error: "File path is required" });
}
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
const checkCommand = `test -x '${escapedPath}' && echo "EXECUTABLE" || echo "NOT_EXECUTABLE"`;
execChannel(sshConn, checkCommand, (checkErr, checkStream) => {
if (checkErr) {
ctx.log.error("SSH executeFile check error:", checkErr);
return res
.status(500)
.json({ error: "Failed to check file executability" });
}
let checkResult = "";
checkStream.on("data", (data) => {
checkResult += data.toString();
});
checkStream.on("close", () => {
if (!checkResult.includes("EXECUTABLE")) {
return res.status(400).json({ error: "File is not executable" });
}
const executeCommand = `cd "$(dirname '${escapedPath}')" && '${escapedPath}' 2>&1; echo "EXIT_CODE:$?"`;
execChannel(sshConn, executeCommand, (err, stream) => {
if (err) {
ctx.log.error("SSH executeFile error:", err);
return res.status(500).json({ error: "Failed to execute file" });
}
let output = "";
let errorOutput = "";
stream.on("data", (data) => {
output += data.toString();
});
stream.stderr.on("data", (data) => {
errorOutput += data.toString();
});
stream.on("close", (code) => {
const exitCodeMatch = output.match(/EXIT_CODE:(\d+)$/);
const actualExitCode = exitCodeMatch
? parseInt(exitCodeMatch[1])
: code;
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
ctx.log.info(
`File execution completed: ${sessionId} (${filePath}, exitCode=${actualExitCode}, outputLength=${cleanOutput.length}, errorLength=${errorOutput.length})`,
);
res.json({
success: true,
exitCode: actualExitCode,
output: cleanOutput,
error: errorOutput,
timestamp: new Date().toISOString(),
});
});
stream.on("error", (streamErr) => {
ctx.log.error("SSH executeFile stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: "Execution stream error" });
}
});
});
});
});
});
/**
* @openapi
* /plugin-api/file-manager/changePermissions:
* post:
* summary: Change file permissions
* description: Changes the permissions of a file on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* permissions:
* type: string
* responses:
* 200:
* description: Permissions changed successfully.
* 400:
* description: Missing required parameters or SSH connection not available.
* 408:
* description: Permission change timed out.
* 500:
* description: Failed to change permissions.
*/
app.post("/changePermissions", async (req, res) => {
const { sessionId, path, permissions } = req.body;
const sshConn = sshSessions[sessionId];
const userId = ctx.currentActor()!;
if (!sshConn || !sshConn.isConnected) {
ctx.log.error(
`SSH connection not found or not connected for changePermissions: ${sessionId} (hasConnection=${!!sshConn}, isConnected=${sshConn?.isConnected})`,
);
return res.status(400).json({ error: "SSH connection not available" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!path) {
return res.status(400).json({ error: "File path is required" });
}
if (!permissions || !/^\d{3,4}$/.test(permissions)) {
return res.status(400).json({
error: "Valid permissions required (e.g., 755, 644)",
});
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
const octalPerms = permissions.slice(-3);
const escapedPath = path.replace(/'/g, "'\"'\"'");
const command = `chmod ${octalPerms} '${escapedPath}' && echo "SUCCESS"`;
ctx.log.info(
`Changing file permissions: ${sessionId} (${path}, permissions=${octalPerms})`,
);
const commandTimeout = setTimeout(() => {
if (!res.headersSent) {
ctx.log.error(
`changePermissions command timeout: ${sessionId} (${path}, permissions=${octalPerms})`,
);
res.status(408).json({
error: "Permission change timed out. SSH connection may be unstable.",
});
}
}, 10000);
execChannel(sshConn, command, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
ctx.log.error(
`SSH changePermissions exec error: ${sessionId} (${path}, permissions=${octalPerms})`,
err,
);
if (!res.headersSent) {
return res
.status(500)
.json({ error: "Failed to change permissions" });
}
return;
}
let outputData = "";
let errorOutput = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (data: Buffer) => {
errorOutput += data.toString();
});
stream.on("close", (code) => {
clearTimeout(commandTimeout);
if (outputData.includes("SUCCESS")) {
ctx.log.info(
`File permissions changed successfully: ${sessionId} (${path}, permissions=${octalPerms})`,
);
if (!res.headersSent) {
res.json({
success: true,
message: "Permissions changed successfully",
});
}
return;
}
if (code !== 0) {
ctx.log.error(
`chmod command failed: ${sessionId} (${path}, permissions=${octalPerms}, exitCode=${code}, error=${errorOutput})`,
);
if (!res.headersSent) {
return res.status(500).json({
error: errorOutput || "Failed to change permissions",
});
}
return;
}
ctx.log.info(
`File permissions changed successfully: ${sessionId} (${path}, permissions=${octalPerms})`,
);
if (!res.headersSent) {
res.json({
success: true,
message: "Permissions changed successfully",
});
}
});
stream.on("error", (streamErr) => {
clearTimeout(commandTimeout);
ctx.log.error(
`SSH changePermissions stream error: ${sessionId} (${path}, permissions=${octalPerms})`,
streamErr,
);
if (!res.headersSent) {
res
.status(500)
.json({ error: "Stream error while changing permissions" });
}
});
});
});
}
@@ -0,0 +1,645 @@
import type { Express, Request, Response } from "express";
import { and, desc, eq } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
/* eslint-disable @typescript-eslint/no-explicit-any */
// The tables come from ctx.db.define, which the SDK hands back untyped, and
// the drizzle handle is the server's own. Typed at this module's edge instead.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export interface BookmarkTables {
table: Table;
pinnedTable: Table;
shortcutsTable: Table;
transferRecentTable: Table;
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === "string" && value.length > 0;
}
/**
* Recent/pinned/shortcut files and recent transfer destinations, adopted from
* core's host.ts and host-file-manager-bookmark-routes.ts. Kept simple, direct
* Drizzle calls rather than a repository layer, since each is a handful of
* lines and nothing else in the plugin reuses them.
*/
export function registerBookmarkRoutes(
app: Express,
ctx: PluginContext,
tables: BookmarkTables,
): void {
const { table, pinnedTable, shortcutsTable, transferRecentTable } = tables;
const client = () => ctx.db.client<Drizzle>();
/**
* @openapi
* /plugin-api/file-manager/recent:
* get:
* summary: Get recent files
* description: Retrieves a list of recent files for a specific host.
* tags:
* - File Manager
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of recent files.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch recent files.
*/
app.get("/recent", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
return res.status(400).json({ error: "Host ID is required" });
}
try {
const drizzle = await client();
const rows = await drizzle
.select()
.from(table)
.where(and(eq(table.userId, userId), eq(table.hostId, hostId)))
.orderBy(desc(table.lastOpened))
.limit(20);
res.json(rows);
} catch (err) {
ctx.log.error("Failed to fetch recent files", err as Error);
res.status(500).json({ error: "Failed to fetch recent files" });
}
});
/**
* @openapi
* /plugin-api/file-manager/recent:
* post:
* summary: Add recent file
* description: Adds a file to the list of recent files for a host.
* tags:
* - File Manager
* responses:
* 200:
* description: Recent file added.
* 400:
* description: Invalid data.
* 500:
* description: Failed to add recent file.
*/
app.post("/recent", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const drizzle = await client();
const existing = await drizzle
.select()
.from(table)
.where(
and(
eq(table.userId, userId),
eq(table.hostId, hostId),
eq(table.path, path),
),
)
.limit(1);
const now = new Date().toISOString();
if (existing[0]) {
await drizzle
.update(table)
.set({ name: name ?? existing[0].name, lastOpened: now })
.where(eq(table.id, existing[0].id));
} else {
await drizzle.insert(table).values({
userId,
hostId,
path,
name: name ?? path,
lastOpened: now,
});
}
await ctx.db.persist();
res.json({ message: "Recent file added" });
} catch (err) {
ctx.log.error("Failed to add recent file", err as Error);
res.status(500).json({ error: "Failed to add recent file" });
}
});
/**
* @openapi
* /plugin-api/file-manager/recent:
* delete:
* summary: Remove recent file
* tags:
* - File Manager
* responses:
* 200:
* description: Recent file removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove recent file.
*/
app.delete("/recent", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const drizzle = await client();
await drizzle
.delete(table)
.where(
and(
eq(table.userId, userId),
eq(table.hostId, hostId),
eq(table.path, path),
),
);
await ctx.db.persist();
res.json({ message: "Recent file removed" });
} catch (err) {
ctx.log.error("Failed to remove recent file", err as Error);
res.status(500).json({ error: "Failed to remove recent file" });
}
});
/**
* @openapi
* /plugin-api/file-manager/pinned:
* get:
* summary: Get pinned files
* tags:
* - File Manager
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of pinned files.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch pinned files.
*/
app.get("/pinned", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
return res.status(400).json({ error: "Host ID is required" });
}
try {
const drizzle = await client();
const rows = await drizzle
.select()
.from(pinnedTable)
.where(
and(eq(pinnedTable.userId, userId), eq(pinnedTable.hostId, hostId)),
)
.orderBy(desc(pinnedTable.pinnedAt));
res.json(rows);
} catch (err) {
ctx.log.error("Failed to fetch pinned files", err as Error);
res.status(500).json({ error: "Failed to fetch pinned files" });
}
});
/**
* @openapi
* /plugin-api/file-manager/pinned:
* post:
* summary: Add pinned file
* tags:
* - File Manager
* responses:
* 200:
* description: File pinned.
* 400:
* description: Invalid data.
* 409:
* description: File already pinned.
* 500:
* description: Failed to pin file.
*/
app.post("/pinned", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const drizzle = await client();
const existing = await drizzle
.select()
.from(pinnedTable)
.where(
and(
eq(pinnedTable.userId, userId),
eq(pinnedTable.hostId, hostId),
eq(pinnedTable.path, path),
),
)
.limit(1);
if (existing[0]) {
return res.status(409).json({ error: "File already pinned" });
}
await drizzle.insert(pinnedTable).values({
userId,
hostId,
path,
name: name ?? path,
pinnedAt: new Date().toISOString(),
});
await ctx.db.persist();
res.json({ message: "File pinned" });
} catch (err) {
ctx.log.error("Failed to pin file", err as Error);
res.status(500).json({ error: "Failed to pin file" });
}
});
/**
* @openapi
* /plugin-api/file-manager/pinned:
* delete:
* summary: Remove pinned file
* tags:
* - File Manager
* responses:
* 200:
* description: Pinned file removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove pinned file.
*/
app.delete("/pinned", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const drizzle = await client();
await drizzle
.delete(pinnedTable)
.where(
and(
eq(pinnedTable.userId, userId),
eq(pinnedTable.hostId, hostId),
eq(pinnedTable.path, path),
),
);
await ctx.db.persist();
res.json({ message: "Pinned file removed" });
} catch (err) {
ctx.log.error("Failed to remove pinned file", err as Error);
res.status(500).json({ error: "Failed to remove pinned file" });
}
});
/**
* @openapi
* /plugin-api/file-manager/shortcuts:
* get:
* summary: Get shortcuts
* tags:
* - File Manager
* parameters:
* - in: query
* name: hostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of shortcuts.
* 400:
* description: Invalid userId or hostId.
* 500:
* description: Failed to fetch shortcuts.
*/
app.get("/shortcuts", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const hostIdQuery = Array.isArray(req.query.hostId)
? req.query.hostId[0]
: req.query.hostId;
const hostId = hostIdQuery ? parseInt(hostIdQuery as string) : null;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
if (!hostId) {
return res.status(400).json({ error: "Host ID is required" });
}
try {
const drizzle = await client();
const rows = await drizzle
.select()
.from(shortcutsTable)
.where(
and(
eq(shortcutsTable.userId, userId),
eq(shortcutsTable.hostId, hostId),
),
)
.orderBy(desc(shortcutsTable.createdAt));
res.json(rows);
} catch (err) {
ctx.log.error("Failed to fetch shortcuts", err as Error);
res.status(500).json({ error: "Failed to fetch shortcuts" });
}
});
/**
* @openapi
* /plugin-api/file-manager/shortcuts:
* post:
* summary: Add shortcut
* tags:
* - File Manager
* responses:
* 200:
* description: Shortcut added.
* 400:
* description: Invalid data.
* 409:
* description: Shortcut already exists.
* 500:
* description: Failed to add shortcut.
*/
app.post("/shortcuts", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const { hostId, path, name } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const drizzle = await client();
const existing = await drizzle
.select()
.from(shortcutsTable)
.where(
and(
eq(shortcutsTable.userId, userId),
eq(shortcutsTable.hostId, hostId),
eq(shortcutsTable.path, path),
),
)
.limit(1);
if (existing[0]) {
return res.status(409).json({ error: "Shortcut already exists" });
}
await drizzle.insert(shortcutsTable).values({
userId,
hostId,
path,
name: name ?? path,
createdAt: new Date().toISOString(),
});
await ctx.db.persist();
res.json({ message: "Shortcut added" });
} catch (err) {
ctx.log.error("Failed to add shortcut", err as Error);
res.status(500).json({ error: "Failed to add shortcut" });
}
});
/**
* @openapi
* /plugin-api/file-manager/shortcuts:
* delete:
* summary: Remove shortcut
* tags:
* - File Manager
* responses:
* 200:
* description: Shortcut removed.
* 400:
* description: Invalid data.
* 500:
* description: Failed to remove shortcut.
*/
app.delete("/shortcuts", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const { hostId, path } = req.body;
if (!isNonEmptyString(userId) || !hostId || !path) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const drizzle = await client();
await drizzle
.delete(shortcutsTable)
.where(
and(
eq(shortcutsTable.userId, userId),
eq(shortcutsTable.hostId, hostId),
eq(shortcutsTable.path, path),
),
);
await ctx.db.persist();
res.json({ message: "Shortcut removed" });
} catch (err) {
ctx.log.error("Failed to remove shortcut", err as Error);
res.status(500).json({ error: "Failed to remove shortcut" });
}
});
/**
* @openapi
* /plugin-api/file-manager/transfer-recent:
* get:
* summary: Get recent transfer destinations
* description: Retrieves recently used host-to-host transfer destinations for a source host.
* tags:
* - File Manager
* parameters:
* - in: query
* name: sourceHostId
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: A list of recent transfer destinations.
* 400:
* description: Invalid userId or sourceHostId.
* 500:
* description: Failed to fetch recent destinations.
*/
app.get("/transfer-recent", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const sourceHostIdQuery = Array.isArray(req.query.sourceHostId)
? req.query.sourceHostId[0]
: req.query.sourceHostId;
const sourceHostId = sourceHostIdQuery
? parseInt(sourceHostIdQuery as string)
: null;
if (!isNonEmptyString(userId)) {
return res.status(400).json({ error: "Invalid userId" });
}
if (!sourceHostId) {
return res.status(400).json({ error: "Source host ID is required" });
}
try {
const drizzle = await client();
const rows = await drizzle
.select()
.from(transferRecentTable)
.where(
and(
eq(transferRecentTable.userId, userId),
eq(transferRecentTable.sourceHostId, sourceHostId),
),
)
.orderBy(desc(transferRecentTable.lastUsed))
.limit(10);
res.json(rows);
} catch (err) {
ctx.log.error(
"Failed to fetch transfer recent destinations",
err as Error,
);
res.status(500).json({ error: "Failed to fetch recent destinations" });
}
});
/**
* @openapi
* /plugin-api/file-manager/transfer-recent:
* post:
* summary: Save a recent transfer destination
* tags:
* - File Manager
* responses:
* 200:
* description: Recent destination saved.
* 400:
* description: Invalid data.
* 500:
* description: Failed to save recent destination.
*/
app.post("/transfer-recent", async (req: Request, res: Response) => {
const userId = ctx.currentActor();
const { sourceHostId, destHostId, destPath, destPathLabel } = req.body;
if (
!isNonEmptyString(userId) ||
!sourceHostId ||
!destHostId ||
!destPath
) {
return res.status(400).json({ error: "Invalid data" });
}
try {
const drizzle = await client();
const existing = await drizzle
.select()
.from(transferRecentTable)
.where(
and(
eq(transferRecentTable.userId, userId),
eq(transferRecentTable.sourceHostId, sourceHostId),
eq(transferRecentTable.destHostId, destHostId),
eq(transferRecentTable.destPath, destPath),
),
)
.limit(1);
const now = new Date().toISOString();
if (existing[0]) {
await drizzle
.update(transferRecentTable)
.set({
destPathLabel: destPathLabel ?? existing[0].destPathLabel,
lastUsed: now,
})
.where(eq(transferRecentTable.id, existing[0].id));
} else {
await drizzle.insert(transferRecentTable).values({
userId,
sourceHostId,
destHostId,
destPath,
destPathLabel: destPathLabel ?? destPath,
lastUsed: now,
});
}
// Keep only the 10 most recent destinations per source host.
const all = await drizzle
.select()
.from(transferRecentTable)
.where(
and(
eq(transferRecentTable.userId, userId),
eq(transferRecentTable.sourceHostId, sourceHostId),
),
)
.orderBy(desc(transferRecentTable.lastUsed));
const stale = all.slice(10);
for (const row of stale) {
await drizzle
.delete(transferRecentTable)
.where(eq(transferRecentTable.id, row.id));
}
await ctx.db.persist();
res.json({ message: "Recent destination saved" });
} catch (err) {
ctx.log.error("Failed to save transfer recent destination", err as Error);
res.status(500).json({ error: "Failed to save recent destination" });
}
});
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,72 @@
export interface DirectTransferEndpoint {
host: string;
port: number;
username: string;
}
export const DIRECT_TRANSFER_MIN_IMPROVEMENT = 0.2;
export const DIRECT_TRANSFER_MIN_BYTES = 32 * 1024 * 1024;
export function shouldBenchmarkDirectTransfer(totalBytes: number): boolean {
return totalBytes >= DIRECT_TRANSFER_MIN_BYTES;
}
export function quoteShell(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function formatHost(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
export function buildDirectSshCommand(
endpoint: DirectTransferEndpoint,
): string {
const target = `${endpoint.username}@${formatHost(endpoint.host)}`;
return [
"ssh",
"-o BatchMode=yes",
"-o ConnectTimeout=5",
"-o StrictHostKeyChecking=yes",
`-p ${endpoint.port}`,
quoteShell(target),
].join(" ");
}
export function buildDirectProbeCommand(
endpoint: DirectTransferEndpoint,
): string {
return `${buildDirectSshCommand(endpoint)} ${quoteShell("command -v rsync >/dev/null")}`;
}
export function buildDirectRsyncCommand(
endpoint: DirectTransferEndpoint,
sourcePaths: string[],
destPath: string,
destIsDirectory: boolean,
): string {
const sshTransport = buildDirectSshCommand(endpoint).replace(/ '[^']+'$/, "");
const targetHost = `${endpoint.username}@${formatHost(endpoint.host)}`;
const targetPath = destIsDirectory
? `${destPath.replace(/\/+$/, "") || "/"}/`
: destPath;
const sources = sourcePaths.map(quoteShell).join(" ");
const target = quoteShell(`${targetHost}:${targetPath}`);
return [
"rsync -a --partial --append-verify --protect-args --info=progress2",
`-e ${quoteShell(sshTransport)}`,
"--",
sources,
target,
].join(" ");
}
export function shouldUseDirectTransfer(
directMs: number,
relayMs: number,
minImprovement = DIRECT_TRANSFER_MIN_IMPROVEMENT,
): boolean {
if (directMs <= 0 || relayMs <= 0) return false;
return directMs <= relayMs * (1 - minImprovement);
}
@@ -0,0 +1,320 @@
import type { Express } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { getMimeType } from "./utils.js";
import { createDownloadStream } from "./download-stream.js";
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
type FileDownloadRoutesDeps = {
ctx: PluginContext;
sshSessions: Record<string, SSHSession>;
scheduleSessionCleanup: (sessionId: string) => void;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
function escapeSingleQuotes(path: string): string {
return path.replace(/'/g, "'\"'\"'");
}
function downloadViaExec(
session: SSHSession,
filePath: string,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const escaped = escapeSingleQuotes(filePath);
execChannel(session, `cat '${escaped}'`, (err, stream) => {
if (err) return reject(err);
const chunks: Buffer[] = [];
let stderr = "";
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
stream.on("close", (code: number) => {
if (code !== 0) {
return reject(
new Error(stderr.trim() || `cat exited with code ${code}`),
);
}
resolve(Buffer.concat(chunks));
});
stream.on("error", reject);
});
});
}
export function registerFileDownloadRoutes(
app: Express,
{
ctx,
sshSessions,
scheduleSessionCleanup,
verifySessionOwnership,
}: FileDownloadRoutesDeps,
): void {
/**
* @openapi
* /plugin-api/file-manager/downloadFile:
* post:
* summary: Download a file
* description: Downloads a file from the remote host. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* hostId:
* type: integer
* userId:
* type: string
* responses:
* 200:
* description: The file content.
* 400:
* description: Missing required parameters or file too large.
* 500:
* description: Failed to download file.
*/
app.post("/downloadFile", async (req, res) => {
const { sessionId, path: filePath, hostId } = req.body;
const userId = ctx.currentActor()!;
const downloadStartTime = Date.now();
if (!sessionId || !filePath) {
ctx.log.warn(
`Missing download parameters: ${sessionId} (hasFilePath=${!!filePath})`,
);
return res.status(400).json({ error: "Missing download parameters" });
}
ctx.log.info(`File download started: ${sessionId} (${filePath})`);
const sshConn = sshSessions[sessionId];
if (!sshConn || !sshConn.isConnected) {
ctx.log.warn(
`SSH session not found or not connected for download: ${sessionId} (isConnected=${sshConn?.isConnected})`,
);
return res
.status(400)
.json({ error: "SSH session not found or not connected" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now();
scheduleSessionCleanup(sessionId);
if (sshConn.scpLegacy) {
ctx.log.info(
`Downloading file via legacy exec/cat (SCP legacy mode): ${sessionId} (${filePath})`,
);
try {
const data = await downloadViaExec(sshConn, filePath);
const fileName = filePath.split("/").pop() || "download";
ctx.log.info(
`File download completed (legacy mode): ${sessionId} (${filePath}, hostId=${hostId}, bytes=${data.length}, duration=${Date.now() - downloadStartTime}ms)`,
);
return res.json({
content: data.toString("base64"),
fileName,
size: data.length,
mimeType: getMimeType(fileName),
path: filePath,
});
} catch (err) {
ctx.log.error("Legacy exec/cat download failed:", err as Error);
return res.status(500).json({
error: `Failed to download file: ${(err as Error).message}`,
});
}
}
ctx.log.info(
`Opening SFTP channel for downloadFile: ${sessionId} (${filePath})`,
);
getSessionSftp(sshConn)
.then((sftp) => {
sftp.stat(filePath, (statErr, stats) => {
if (statErr) {
ctx.log.error("File stat failed for download:", statErr);
return res
.status(500)
.json({ error: `Cannot access file: ${statErr.message}` });
}
if (!stats.isFile()) {
ctx.log.warn(
`Attempted to download non-file: ${sessionId} (${filePath}, isFile=${stats.isFile()}, isDirectory=${stats.isDirectory()})`,
);
return res
.status(400)
.json({ error: "Cannot download directories or special files" });
}
const MAX_FILE_SIZE = 5 * 1024 * 1024 * 1024;
if (stats.size > MAX_FILE_SIZE) {
ctx.log.warn(
`File too large for download: ${sessionId} (${filePath}, fileSize=${stats.size}, maxSize=${MAX_FILE_SIZE})`,
);
return res.status(400).json({
error: `File too large. Maximum size is ${MAX_FILE_SIZE / 1024 / 1024}MB, file is ${(stats.size / 1024 / 1024).toFixed(2)}MB`,
});
}
sftp.readFile(filePath, (readErr, data) => {
if (readErr) {
ctx.log.error("File read failed for download:", readErr);
return res
.status(500)
.json({ error: `Failed to read file: ${readErr.message}` });
}
const base64Content = data.toString("base64");
const fileName = filePath.split("/").pop() || "download";
ctx.log.info(
`File download completed: ${sessionId} (${filePath}, hostId=${hostId}, bytes=${stats.size}, duration=${Date.now() - downloadStartTime}ms)`,
);
res.json({
content: base64Content,
fileName: fileName,
size: stats.size,
mimeType: getMimeType(fileName),
path: filePath,
});
});
});
})
.catch((err) => {
ctx.log.error("SFTP connection failed for download:", err);
return res.status(500).json({ error: "SFTP connection failed" });
});
});
/**
* @openapi
* /plugin-api/file-manager/downloadFileStream:
* post:
* summary: Stream-download a file
* description: Downloads a file as a binary stream. Uses SCP legacy mode (cat over exec) when the host has scpLegacy enabled, otherwise uses SFTP.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* responses:
* 200:
* description: Binary file stream.
* 400:
* description: Missing required parameters.
* 500:
* description: Download failed.
*/
app.post("/downloadFileStream", async (req, res) => {
const { sessionId, path: filePath } = req.body;
const userId = ctx.currentActor()!;
if (!sessionId || !filePath) {
return res.status(400).json({ error: "Missing download parameters" });
}
const sshConn = sshSessions[sessionId];
if (!sshConn?.isConnected) {
return res
.status(400)
.json({ error: "SSH session not found or not connected" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
sshConn.lastActive = Date.now();
const fileName = filePath.split("/").pop() || "download";
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader(
"Content-Disposition",
`attachment; filename="${encodeURIComponent(fileName)}"`,
);
if (sshConn.scpLegacy) {
ctx.log.info(
`Streaming file via legacy exec/cat (SCP legacy mode): ${sessionId} (${filePath})`,
);
const escaped = escapeSingleQuotes(filePath);
execChannel(sshConn, `cat '${escaped}'`, (err, stream) => {
if (err) {
if (!res.headersSent) {
res.status(500).json({ error: `Download failed: ${err.message}` });
}
return;
}
stream.on("error", (streamErr: Error) => {
if (!res.headersSent) {
res
.status(500)
.json({ error: `Download failed: ${streamErr.message}` });
} else {
res.destroy();
}
});
stream.pipe(res);
});
return;
}
try {
const sftp = await getSessionSftp(sshConn);
const stats = await new Promise<{ size: number; isFile: () => boolean }>(
(resolve, reject) => {
sftp.stat(filePath, (err, s) => (err ? reject(err) : resolve(s)));
},
);
if (!stats.isFile()) {
return res.status(400).json({ error: "Cannot download directories" });
}
res.setHeader("Content-Length", String(stats.size));
if (res.destroyed) return;
const readStream = createDownloadStream(sftp, filePath, stats.size);
res.on("close", () => readStream.destroy());
readStream.on("error", (err) => {
if (!res.headersSent) {
res.removeHeader("Content-Length");
res.status(500).json({ error: `Download failed: ${err.message}` });
} else {
res.destroy();
}
});
readStream.pipe(res);
} catch (err) {
if (!res.headersSent) {
res
.status(500)
.json({ error: `Download failed: ${(err as Error).message}` });
}
}
});
}
@@ -0,0 +1,68 @@
import { Readable } from "node:stream";
import type { SFTPWrapper } from "ssh2";
import {
promisifySftpClose,
promisifySftpOpen,
SFTP_OPEN_READ,
} from "./sftp-promisify.js";
const CHUNK_SIZE = 32 * 1024;
const CONCURRENCY = 8;
/** Bounded read-ahead; batches retain file order even if replies arrive out of order. */
export function createDownloadStream(
sftp: SFTPWrapper,
path: string,
size: number,
): Readable {
const stream = Readable.from(readFile(), {
objectMode: false,
highWaterMark: CHUNK_SIZE,
});
return stream;
async function readChunk(handle: Buffer, position: number, length: number) {
const buffer = Buffer.allocUnsafe(length);
let offset = 0;
while (offset < length && !stream.destroyed) {
const bytesRead = await new Promise<number>((resolve, reject) => {
sftp.read(
handle,
buffer,
offset,
length - offset,
position + offset,
(err, bytes) => (err ? reject(err) : resolve(bytes)),
);
});
if (bytesRead === 0)
throw new Error("File ended before the advertised size");
offset += bytesRead;
}
return buffer;
}
async function* readFile() {
if (stream.destroyed) return;
const handle = await promisifySftpOpen(sftp, path, SFTP_OPEN_READ, 0o666);
try {
for (let position = 0; position < size && !stream.destroyed;) {
const reads: Promise<Buffer>[] = [];
for (let i = 0; i < CONCURRENCY && position < size; i++) {
const length = Math.min(CHUNK_SIZE, size - position);
reads.push(readChunk(handle, position, length));
position += length;
}
// Settle all reads before closing the shared handle after an error.
const results = await Promise.allSettled(reads);
if (stream.destroyed) return;
for (const result of results) {
if (result.status === "rejected") throw result.reason;
yield result.value;
}
}
} finally {
await promisifySftpClose(sftp, handle);
}
}
}
@@ -0,0 +1,6 @@
export function getErrorMessage(
error: unknown,
fallback = "Unknown error",
): string {
return error instanceof Error ? error.message : fallback;
}
@@ -0,0 +1,35 @@
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
/** The SFTP login directory also respects custom homes and chroot roots. */
export async function resolveHomeDirectory(
session: SSHSession,
): Promise<string> {
try {
const sftp = await getSessionSftp(session);
return await new Promise<string>((resolve, reject) => {
sftp.realpath(".", (error, path) => {
if (error) reject(error);
else if (!path) reject(new Error("Empty SFTP login directory"));
else resolve(path);
});
});
} catch {
// Keep the existing shell-only file browser usable without SFTP.
return new Promise<string>((resolve, reject) => {
execChannel(session, "pwd -P", (error, stream) => {
if (error) return reject(error);
let output = "";
stream.on("data", (chunk: Buffer) => {
output += chunk.toString();
});
stream.on("error", reject);
stream.on("close", (code: number) => {
const path = output.replace(/[\r\n]+$/, "");
if (code !== 0 || !path.startsWith("/")) {
reject(new Error("Could not resolve the remote login directory"));
} else resolve(path);
});
});
});
}
}
@@ -0,0 +1,119 @@
import type { PluginLogger } from "@termix/plugin-sdk/backend";
export type ConnectionStage =
| "dns"
| "tcp"
| "handshake"
| "auth"
| "connected"
| "connection"
| "error"
| "proxy"
| "jump"
| "sftp_connecting"
| "sftp_auth"
| "sftp_connected";
export type LogEntry = {
id: string;
timestamp: Date;
type: "info" | "success" | "warning" | "error";
stage: ConnectionStage;
message: string;
details?: Record<string, unknown> | string;
};
export function createConnectionLog(
type: LogEntry["type"],
stage: ConnectionStage,
message: string,
details?: Record<string, unknown>,
): Omit<LogEntry, "id" | "timestamp"> {
return { type, stage, message, details };
}
function normalizeHostAddress(value: unknown): string {
if (typeof value !== "string") return "";
return value
.replace(/^\[|\]$/g, "")
.trim()
.toLowerCase();
}
/**
* Whether a numeric host id resolved to a different machine than the client
* meant. Ids from the desktop app and a sync server drift apart, and the
* resolved row supplies the credentials, so a mismatch must be refused.
*/
export function hostAddressMismatch(
clientAddress: unknown,
resolvedAddress: unknown,
): boolean {
const resolved = normalizeHostAddress(resolvedAddress);
if (!resolved) return false;
return resolved !== normalizeHostAddress(clientAddress);
}
export function resolveServerHostId(
clientHostId: number | null,
resolvedHost: { id?: unknown } | null,
): number | null {
return typeof resolvedHost?.id === "number" ? resolvedHost.id : clientHostId;
}
export class HostAddressMismatchError extends Error {
constructor() {
super(
"Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " +
"The host ids on this device and on the sync server have drifted apart. " +
'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.',
);
this.name = "HostAddressMismatchError";
}
}
export class HostNotOnThisServerError extends Error {
constructor() {
super(
"This host does not exist on the sync server, so the connection was refused. " +
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.',
);
this.name = "HostNotOnThisServerError";
}
}
type Meta = Record<string, unknown>;
export interface FileLogger {
info: (message: string, meta?: Meta) => void;
success: (message: string, meta?: Meta) => void;
warn: (message: string, meta?: Meta) => void;
error: (message: string, error?: unknown, meta?: Meta) => void;
}
function withMeta(message: string, meta?: Meta): string {
if (!meta || Object.keys(meta).length === 0) return message;
try {
return `${message} ${JSON.stringify(meta)}`;
} catch {
return message;
}
}
/** ctx.log takes a plain message; this keeps the structured fields readable. */
export function createFileLogger(log: PluginLogger): FileLogger {
return {
info: (message, meta) => log.info(withMeta(message, meta)),
success: (message, meta) => log.info(withMeta(message, meta)),
warn: (message, meta) => log.warn(withMeta(message, meta)),
error: (message, error, meta) => {
if (error instanceof Error) {
log.error(withMeta(message, meta), error);
} else if (error && typeof error === "object" && !meta) {
log.error(withMeta(message, error as Meta));
} else {
log.error(withMeta(message, meta));
}
},
};
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,476 @@
import { resolveHomeDirectory } from "./home-directory.js";
import { getErrorMessage } from "./error-message.js";
import type { Express } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { execChannel, getSessionSftp, type SSHSession } from "./session.js";
import {
formatMtime,
isExecutableFile,
modeToPermissions,
parseLsDateToTimestamp,
} from "./utils.js";
type FileListingRoutesDeps = {
ctx: PluginContext;
sshSessions: Record<string, SSHSession>;
activeListRequests: Record<string, boolean>;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
export function registerFileListingRoutes(
app: Express,
{
ctx,
sshSessions,
activeListRequests,
verifySessionOwnership,
}: FileListingRoutesDeps,
): void {
/**
* @openapi
* /plugin-api/file-manager/listFiles:
* get:
* summary: List files in a directory
* description: Lists the files and directories in a given path on the remote host.
* tags:
* - File Manager
* parameters:
* - in: query
* name: sessionId
* required: true
* schema:
* type: string
* - in: query
* name: path
* required: true
* schema:
* type: string
* responses:
* 200:
* description: A list of files and directories.
* 400:
* description: Session ID is required or SSH connection not established.
* 500:
* description: Failed to list files.
*/
app.get("/listFiles", async (req, res) => {
const sessionId = req.query.sessionId as string;
const sshConn = sshSessions[sessionId];
let sshPath = (req.query.path as string) || ".";
const userId = ctx.currentActor()!;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (sshPath === ".") {
try {
sshPath = await resolveHomeDirectory(sshConn);
} catch (error) {
return res.status(500).json({
error: getErrorMessage(error, "Could not resolve home directory"),
});
}
}
// Drop concurrent requests for the same session+path — each would open
// a new SSH channel and can exceed the server's per-connection channel limit.
const listKey = `${sessionId}:${sshPath}`;
if (activeListRequests[listKey]) {
return res
.status(409)
.json({ error: "List request already in progress" });
}
activeListRequests[listKey] = true;
res.on("finish", () => {
delete activeListRequests[listKey];
});
sshConn.lastActive = Date.now();
sshConn.activeOperations++;
const trySFTP = () => {
try {
ctx.log.info(
`Opening SFTP channel for listFiles: ${sessionId} (${sshPath})`,
);
getSessionSftp(sshConn)
.then((sftp) => {
sftp.readdir(sshPath, (readdirErr, list) => {
if (readdirErr) {
ctx.log.warn(
`SFTP readdir failed, trying fallback: ${readdirErr.message}`,
);
tryFallbackMethod();
return;
}
const symlinks: Array<{ index: number; path: string }> = [];
const files: Array<{
name: string;
type: string;
size: number | undefined;
modified: string;
modifiedTimestamp: number;
permissions: string;
owner: string;
group: string;
linkTarget: string | undefined;
path: string;
executable: boolean;
}> = [];
for (const entry of list) {
if (entry.filename === "." || entry.filename === "..") continue;
const attrs = entry.attrs;
const permissions = modeToPermissions(attrs.mode);
const isDirectory = attrs.isDirectory();
const isLink = attrs.isSymbolicLink();
const fileEntry = {
name: entry.filename,
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : attrs.size,
modified: formatMtime(attrs.mtime),
modifiedTimestamp: attrs.mtime,
permissions,
owner: String(attrs.uid),
group: String(attrs.gid),
linkTarget: undefined as string | undefined,
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`,
executable:
!isDirectory && !isLink
? isExecutableFile(permissions, entry.filename)
: false,
};
if (isLink) {
symlinks.push({ index: files.length, path: fileEntry.path });
}
files.push(fileEntry);
}
if (symlinks.length === 0) {
sshConn.activeOperations--;
return res.json({ files, path: sshPath });
}
let resolved = 0;
let responded = false;
const sendResponse = () => {
if (responded) return;
responded = true;
sshConn.activeOperations--;
res.json({ files, path: sshPath });
};
const readlinkTimeout = setTimeout(sendResponse, 5000);
for (const link of symlinks) {
sftp.readlink(link.path, (linkErr, target) => {
resolved++;
if (!linkErr && target) {
files[link.index].linkTarget = target;
}
if (resolved === symlinks.length) {
clearTimeout(readlinkTimeout);
sendResponse();
}
});
}
});
})
.catch((err: Error) => {
ctx.log.warn(
`SFTP failed for listFiles, trying fallback: ${err.message}`,
);
const isChannelFailure =
err.message.toLowerCase().includes("channel open failure") ||
err.message.toLowerCase().includes("open failed");
if (isChannelFailure) {
sshConn.isConnected = false;
sshConn.sftp = undefined;
}
tryFallbackMethod();
});
} catch (sftpErr: unknown) {
const errMsg = getErrorMessage(sftpErr);
ctx.log.warn(`SFTP connection error, trying fallback: ${errMsg}`);
tryFallbackMethod();
}
};
const tryFallbackMethod = () => {
if (!sshConn?.isConnected) {
sshConn.activeOperations--;
return res
.status(503)
.json({ error: "SSH session disconnected", disconnected: true });
}
try {
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
execChannel(
sshConn,
`command ls -la --color=never '${escapedPath}'`,
(err, stream) => {
if (err) {
sshConn.activeOperations--;
ctx.log.error("SSH listFiles error:", err);
return res.status(500).json({ error: err.message });
}
let data = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on("close", (code) => {
if (code !== 0) {
const isPermissionDenied =
errorData.toLowerCase().includes("permission denied") ||
errorData.toLowerCase().includes("access denied");
if (isPermissionDenied) {
if (sshConn.sudoPassword) {
ctx.log.info(
`Permission denied for listFiles, retrying with sudo: ${sshPath}`,
);
tryWithSudo();
return;
}
sshConn.activeOperations--;
ctx.log.warn(
`Permission denied for listFiles, sudo required: ${sshPath}`,
);
return res.status(403).json({
error: `Permission denied: Cannot access ${sshPath}`,
needsSudo: true,
path: sshPath,
});
}
sshConn.activeOperations--;
ctx.log.error(
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
return res
.status(500)
.json({ error: `Command failed: ${errorData}` });
}
sshConn.activeOperations--;
const lines = data.split("\n").filter((line) => line.trim());
const files = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.split(/\s+/);
if (parts.length >= 9) {
const permissions = parts[0];
const owner = parts[2];
const group = parts[3];
const size = parseInt(parts[4], 10);
let dateStr = "";
const nameStartIndex = 8;
if (parts[5] && parts[6] && parts[7]) {
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
}
const name = parts.slice(nameStartIndex).join(" ");
const isDirectory = permissions.startsWith("d");
const isLink = permissions.startsWith("l");
if (name === "." || name === "..") continue;
let actualName = name;
let linkTarget = undefined;
if (isLink && name.includes(" -> ")) {
const linkParts = name.split(" -> ");
actualName = linkParts[0];
linkTarget = linkParts[1];
}
files.push({
name: actualName,
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : size,
modified: dateStr,
modifiedTimestamp: parseLsDateToTimestamp(dateStr),
permissions,
owner,
group,
linkTarget,
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
executable:
!isDirectory && !isLink
? isExecutableFile(permissions, actualName)
: false,
});
}
}
res.json({ files, path: sshPath });
});
},
);
} catch (execErr: unknown) {
sshConn.activeOperations--;
const errMsg = getErrorMessage(execErr);
ctx.log.error(`Fallback listFiles exec failed: ${errMsg}`);
if (!res.headersSent) {
return res.status(500).json({ error: errMsg });
}
}
};
const tryWithSudo = () => {
try {
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
const escapedPassword = sshConn.sudoPassword!.replace(/'/g, "'\"'\"'");
const sudoCommand = `echo '${escapedPassword}' | sudo -S /bin/ls -la --color=never '${escapedPath}' 2>&1`;
execChannel(sshConn, sudoCommand, (err, stream) => {
if (err) {
sshConn.activeOperations--;
ctx.log.error("SSH sudo listFiles error:", err);
return res.status(500).json({ error: err.message });
}
let data = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on("close", (code) => {
sshConn.activeOperations--;
data = data.replace(/\[sudo\] password for .+?:\s*/g, "");
if (
data.toLowerCase().includes("sorry, try again") ||
data.toLowerCase().includes("incorrect password") ||
errorData.toLowerCase().includes("sorry, try again")
) {
sshConn.sudoPassword = undefined;
return res.status(403).json({
error: "Sudo authentication failed. Please try again.",
needsSudo: true,
sudoFailed: true,
path: sshPath,
});
}
if (code !== 0 && !data.trim()) {
ctx.log.error(
`SSH sudo listFiles failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
return res
.status(500)
.json({ error: `Sudo command failed: ${errorData || data}` });
}
const lines = data.split("\n").filter((line) => line.trim());
const files: Array<{
name: string;
type: string;
size: number | undefined;
modified: string;
modifiedTimestamp: number;
permissions: string;
owner: string;
group: string;
linkTarget: string | undefined;
path: string;
executable: boolean;
}> = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.split(/\s+/);
if (parts.length >= 9) {
const permissions = parts[0];
const owner = parts[2];
const group = parts[3];
const size = parseInt(parts[4], 10);
let dateStr = "";
const nameStartIndex = 8;
if (parts[5] && parts[6] && parts[7]) {
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
}
const name = parts.slice(nameStartIndex).join(" ");
const isDirectory = permissions.startsWith("d");
const isLink = permissions.startsWith("l");
if (name === "." || name === "..") continue;
let actualName = name;
let linkTarget = undefined;
if (isLink && name.includes(" -> ")) {
const linkParts = name.split(" -> ");
actualName = linkParts[0];
linkTarget = linkParts[1];
}
files.push({
name: actualName,
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : size,
modified: dateStr,
modifiedTimestamp: parseLsDateToTimestamp(dateStr),
permissions,
owner,
group,
linkTarget,
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
executable:
!isDirectory && !isLink
? isExecutableFile(permissions, actualName)
: false,
});
}
}
res.json({ files, path: sshPath });
});
});
} catch (execErr: unknown) {
sshConn.activeOperations--;
const errMsg = getErrorMessage(execErr);
ctx.log.error(`Sudo listFiles exec failed: ${errMsg}`);
if (!res.headersSent) {
return res.status(500).json({ error: errMsg });
}
}
};
trySFTP();
});
}
@@ -0,0 +1,39 @@
import { isWindowsSftpPath, sftpPathToLocalPath } from "./transfer-paths.js";
export interface DeleteCommand {
command: string;
commandWithSuccess: string;
}
function quotePosixPath(path: string): string {
return `'${path.replace(/'/g, "'\"'\"'")}'`;
}
function quotePowerShellLiteral(value: string): string {
return `'${value.replace(/'/g, "''")}'`;
}
export function buildDeleteCommand(
itemPath: string,
isDirectory: boolean,
): DeleteCommand {
if (isWindowsSftpPath(itemPath)) {
const path = quotePowerShellLiteral(sftpPathToLocalPath(itemPath));
const command = isDirectory
? `Remove-Item -LiteralPath ${path} -Recurse -Force -ErrorAction Stop`
: `Remove-Item -LiteralPath ${path} -Force -ErrorAction Stop`;
return {
command,
commandWithSuccess: `${command}; if ($?) { Write-Output "SUCCESS" }`,
};
}
const path = quotePosixPath(itemPath);
const command = isDirectory ? `rm -rf ${path}` : `rm -f ${path}`;
return {
command,
commandWithSuccess: `${command} && echo "SUCCESS"`,
};
}
@@ -0,0 +1,947 @@
import type { Express, Request, Response } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
execChannel,
execWithSudo,
getSessionSftp,
type SSHSession,
} from "./session.js";
import { ensureDirectoryTreeSftp } from "./transfer-sftp-dir.js";
import { buildDeleteCommand } from "./operation-commands.js";
import {
emptyTrash,
listTrash,
moveToTrash,
permanentlyDeleteTrashItem,
restoreTrashItem,
} from "./trash-service.js";
type FileOperationRoutesDeps = {
ctx: PluginContext;
sshSessions: Record<string, SSHSession>;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
};
const TRASH_RETENTION_DAYS_KEY = "trashRetentionDays";
export function registerFileOperationRoutes(
app: Express,
{ ctx, sshSessions, verifySessionOwnership }: FileOperationRoutesDeps,
): void {
// Trash retention is an install-wide admin setting, not a per-host check.
const canManageRetention = () => ctx.rbac.has("admin.settings.manage");
const getTrashRetentionDays = async (): Promise<number> => {
try {
const value = Number(await ctx.settings.get(TRASH_RETENTION_DAYS_KEY));
return Number.isInteger(value) && value >= 1 && value <= 3650 ? value : 7;
} catch {
return 7;
}
};
async function ownedSession(
req: Request,
res: Response,
): Promise<SSHSession | null> {
const sessionId = String(req.body?.sessionId ?? req.query?.sessionId ?? "");
const session = sshSessions[sessionId];
if (!sessionId || !session?.isConnected) {
res.status(400).json({ error: "SSH connection not established" });
return null;
}
if (!verifySessionOwnership(session, ctx.currentActor()!)) {
res.status(403).json({ error: "Session access denied" });
return null;
}
session.lastActive = Date.now();
return session;
}
/**
* @openapi
* /plugin-api/file-manager/trash:
* get:
* summary: List items in the host's Termix trash
* tags: [File Manager]
* parameters:
* - in: query
* name: sessionId
* required: true
* schema: { type: string }
* responses:
* 200: { description: Trashed items, the retention in days and whether the caller may change it. }
* 400: { description: The session is not connected. }
* 403: { description: The session belongs to someone else. }
*/
app.get("/trash", async (req, res) => {
const session = await ownedSession(req, res);
if (!session) return;
try {
const retentionDays = await getTrashRetentionDays();
res.json({
items: await listTrash(await getSessionSftp(session), retentionDays),
retentionDays,
canManageRetention: await canManageRetention(),
});
} catch (error) {
ctx.log.error("Failed to list trash", error as Error);
res.status(500).json({ error: (error as Error).message });
}
});
/**
* @openapi
* /plugin-api/file-manager/trash/{id}/restore:
* post:
* summary: Restore a trashed item to where it was
* tags: [File Manager]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: string }
* - in: query
* name: sessionId
* required: true
* schema: { type: string }
* responses:
* 200: { description: The restored path. }
* 409: { description: Something already exists at the original path. }
*/
app.post("/trash/:id/restore", async (req, res) => {
const session = await ownedSession(req, res);
if (!session) return;
try {
res.json({
item: await restoreTrashItem(
await getSessionSftp(session),
req.params.id,
),
});
} catch (error) {
const message = (error as Error).message;
res
.status(message.includes("already exists") ? 409 : 500)
.json({ error: message });
}
});
/**
* @openapi
* /plugin-api/file-manager/trash/{id}:
* delete:
* summary: Delete a trashed item for good
* tags: [File Manager]
* parameters:
* - in: path
* name: id
* required: true
* schema: { type: string }
* - in: query
* name: sessionId
* required: true
* schema: { type: string }
* responses:
* 200: { description: Deleted. }
* 500: { description: The delete failed on the host. }
*/
app.delete("/trash/:id", async (req, res) => {
const session = await ownedSession(req, res);
if (!session) return;
try {
await permanentlyDeleteTrashItem(
await getSessionSftp(session),
req.params.id,
);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* @openapi
* /plugin-api/file-manager/trash:
* delete:
* summary: Empty the host's Termix trash
* tags: [File Manager]
* parameters:
* - in: query
* name: sessionId
* required: true
* schema: { type: string }
* responses:
* 200: { description: How many items were deleted. }
* 500: { description: The delete failed on the host. }
*/
app.delete("/trash", async (req, res) => {
const session = await ownedSession(req, res);
if (!session) return;
try {
res.json({ deleted: await emptyTrash(await getSessionSftp(session)) });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* @openapi
* /plugin-api/file-manager/trash-retention:
* put:
* summary: Set how long trashed items are kept (admin only)
* tags: [File Manager]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [retentionDays]
* properties:
* retentionDays: { type: integer, minimum: 1, maximum: 3650 }
* responses:
* 200: { description: The new retention. }
* 400: { description: Out of range. }
* 403: { description: Admin access required. }
*/
app.put("/trash-retention", async (req, res) => {
if (!(await canManageRetention())) {
return res.status(403).json({ error: "Admin access required" });
}
const retentionDays = Number(req.body?.retentionDays);
if (
!Number.isInteger(retentionDays) ||
retentionDays < 1 ||
retentionDays > 3650
) {
return res
.status(400)
.json({ error: "Retention must be between 1 and 3650 days" });
}
await ctx.settings.set(TRASH_RETENTION_DAYS_KEY, String(retentionDays));
return res.json({ retentionDays });
});
/**
* @openapi
* /plugin-api/file-manager/createFile:
* post:
* summary: Create a file
* description: Creates an empty file on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* fileName:
* type: string
* responses:
* 200:
* description: File created successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 500:
* description: Failed to create file.
*/
app.post("/createFile", async (req, res) => {
const { sessionId, path: filePath, fileName } = req.body;
const sshConn = sshSessions[sessionId];
const userId = ctx.currentActor()!;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!filePath || !fileName) {
return res.status(400).json({ error: "File path and name are required" });
}
sshConn.lastActive = Date.now();
const fullPath = filePath.endsWith("/")
? filePath + fileName
: filePath + "/" + fileName;
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`;
execChannel(sshConn, createCommand, (err, stream) => {
if (err) {
ctx.log.error("SSH createFile error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let outputData = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
ctx.log.error(`Permission denied creating file: ${fullPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot create file ${fullPath}. Check directory permissions.`,
});
}
return;
}
});
stream.on("close", (code) => {
if (outputData.includes("SUCCESS")) {
if (!res.headersSent) {
res.json({
message: "File created successfully",
path: fullPath,
toast: { type: "success", message: `File created: ${fullPath}` },
});
}
return;
}
if (code !== 0) {
ctx.log.error(
`SSH createFile command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
if (!res.headersSent) {
return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: {
type: "error",
message: `File creation failed: ${errorData}`,
},
});
}
return;
}
if (!res.headersSent) {
res.json({
message: "File created successfully",
path: fullPath,
toast: { type: "success", message: `File created: ${fullPath}` },
});
}
});
stream.on("error", (streamErr) => {
ctx.log.error("SSH createFile stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
/**
* @openapi
* /plugin-api/file-manager/createFolder:
* post:
* summary: Create a folder
* description: Creates a new folder on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* folderName:
* type: string
* responses:
* 200:
* description: Folder created successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 500:
* description: Failed to create folder.
*/
app.post("/createFolder", async (req, res) => {
const { sessionId, path: folderPath, folderName } = req.body;
const sshConn = sshSessions[sessionId];
const userId = ctx.currentActor()!;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!folderPath || !folderName) {
return res
.status(400)
.json({ error: "Folder path and name are required" });
}
sshConn.lastActive = Date.now();
const fullPath = folderPath.endsWith("/")
? folderPath + folderName
: folderPath + "/" + folderName;
ctx.log.info(`Creating directory: ${sessionId} (${fullPath})`);
try {
await ensureDirectoryTreeSftp(await getSessionSftp(sshConn), fullPath);
res.json({
message: "Folder created successfully",
path: fullPath,
toast: { type: "success", message: `Folder created: ${fullPath}` },
});
} catch (error) {
ctx.log.error("SFTP createFolder failed", error as Error);
const code = (error as { code?: string | number }).code;
res.status(code === 3 || code === "EACCES" ? 403 : 500).json({
error: (error as Error).message,
});
}
});
/**
* @openapi
* /plugin-api/file-manager/deleteItem:
* delete:
* summary: Delete a file or directory
* description: Deletes a file or directory on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* path:
* type: string
* isDirectory:
* type: boolean
* responses:
* 200:
* description: Item deleted successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 500:
* description: Failed to delete item.
*/
app.delete("/deleteItem", async (req, res) => {
const { sessionId, path: itemPath, isDirectory, permanent } = req.body;
const sshConn = sshSessions[sessionId];
const userId = ctx.currentActor()!;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!itemPath) {
return res.status(400).json({ error: "Item path is required" });
}
ctx.log.info(
`Deleting item: ${sessionId} (${itemPath}, type=${isDirectory ? "directory" : "file"})`,
);
sshConn.lastActive = Date.now();
if (!permanent) {
try {
const sftp = await getSessionSftp(sshConn);
const retentionDays = await getTrashRetentionDays();
await listTrash(sftp, retentionDays);
const item = await moveToTrash(sftp, itemPath);
ctx.log.info(
`Item moved to trash: ${sessionId} (${itemPath}, trashId=${item.id})`,
);
return res.json({
message: "Item moved to trash",
path: itemPath,
trashItem: item,
});
} catch (error) {
ctx.log.error(
`Failed to move item to trash: ${sessionId} (${itemPath})`,
error as Error,
);
return res.status(409).json({
error: (error as Error).message,
trashUnavailable: true,
});
}
}
const { command: deleteCommand, commandWithSuccess } = buildDeleteCommand(
itemPath,
Boolean(isDirectory),
);
const executeDelete = (useSudo: boolean): Promise<void> => {
return new Promise((resolve) => {
if (useSudo && sshConn.sudoPassword) {
execWithSudo(sshConn, deleteCommand, sshConn.sudoPassword).then(
(result) => {
if (
result.code === 0 ||
(!result.stderr.includes("Permission denied") &&
!result.stdout.includes("Permission denied"))
) {
res.json({
message: "Item deleted successfully",
path: itemPath,
toast: {
type: "success",
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
},
});
} else {
res.status(500).json({
error: `Delete failed: ${result.stderr || result.stdout}`,
});
}
resolve();
},
);
return;
}
execChannel(sshConn, commandWithSuccess, (err, stream) => {
if (err) {
ctx.log.error("SSH deleteItem error:", err);
res.status(500).json({ error: err.message });
resolve();
return;
}
let outputData = "";
let errorData = "";
let permissionDenied = false;
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
permissionDenied = true;
}
});
stream.on("close", (code) => {
if (permissionDenied) {
if (sshConn.sudoPassword) {
executeDelete(true).then(resolve);
return;
}
ctx.log.error(`Permission denied deleting: ${itemPath}`);
res.status(403).json({
error: `Permission denied: Cannot delete ${itemPath}.`,
needsSudo: true,
});
resolve();
return;
}
if (outputData.includes("SUCCESS")) {
ctx.log.info(
`Item deleted successfully: ${sessionId} (${itemPath})`,
);
res.json({
message: "Item deleted successfully",
path: itemPath,
toast: {
type: "success",
message: `${isDirectory ? "Directory" : "File"} deleted: ${itemPath}`,
},
});
} else {
const detail =
errorData.trim() ||
outputData.trim() ||
`command exited with code ${code} and produced no output (the remote shell may not support the delete command)`;
ctx.log.error(
`Delete failed for ${itemPath}: ${detail} (sessionId=${sessionId})`,
);
res.status(500).json({
error: `Delete failed: ${detail}`,
});
}
resolve();
});
stream.on("error", (streamErr) => {
ctx.log.error("SSH deleteItem stream error:", streamErr);
res
.status(500)
.json({ error: `Stream error: ${streamErr.message}` });
resolve();
});
});
});
};
await executeDelete(false);
});
/**
* @openapi
* /plugin-api/file-manager/renameItem:
* put:
* summary: Rename a file or directory
* description: Renames a file or directory on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* oldPath:
* type: string
* newName:
* type: string
* responses:
* 200:
* description: Item renamed successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 500:
* description: Failed to rename item.
*/
app.put("/renameItem", async (req, res) => {
const { sessionId, oldPath, newName } = req.body;
const sshConn = sshSessions[sessionId];
const userId = ctx.currentActor()!;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!oldPath || !newName) {
return res
.status(400)
.json({ error: "Old path and new name are required" });
}
sshConn.lastActive = Date.now();
const oldDir = oldPath.substring(0, oldPath.lastIndexOf("/") + 1);
const newPath = oldDir + newName;
ctx.log.info(`Renaming item: ${sessionId} (${oldPath} -> ${newPath})`);
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
execChannel(sshConn, renameCommand, (err, stream) => {
if (err) {
ctx.log.error("SSH renameItem error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let outputData = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
ctx.log.error(`Permission denied renaming: ${oldPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot rename ${oldPath}. Check file permissions.`,
});
}
return;
}
});
stream.on("close", (code) => {
if (outputData.includes("SUCCESS")) {
ctx.log.info(
`Item renamed successfully: ${sessionId} (${oldPath} -> ${newPath})`,
);
if (!res.headersSent) {
res.json({
message: "Item renamed successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item renamed: ${oldPath} -> ${newPath}`,
},
});
}
return;
}
if (code !== 0) {
ctx.log.error(
`SSH renameItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
if (!res.headersSent) {
return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: { type: "error", message: `Rename failed: ${errorData}` },
});
}
return;
}
ctx.log.info(
`Item renamed successfully: ${sessionId} (${oldPath} -> ${newPath})`,
);
if (!res.headersSent) {
res.json({
message: "Item renamed successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item renamed: ${oldPath} -> ${newPath}`,
},
});
}
});
stream.on("error", (streamErr) => {
ctx.log.error("SSH renameItem stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
/**
* @openapi
* /plugin-api/file-manager/moveItem:
* put:
* summary: Move a file or directory
* description: Moves a file or directory on the remote host.
* tags:
* - File Manager
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* sessionId:
* type: string
* oldPath:
* type: string
* newPath:
* type: string
* responses:
* 200:
* description: Item moved successfully.
* 400:
* description: Missing required parameters or SSH connection not established.
* 403:
* description: Permission denied.
* 408:
* description: Move operation timed out.
* 500:
* description: Failed to move item.
*/
app.put("/moveItem", async (req, res) => {
const { sessionId, oldPath, newPath } = req.body;
const sshConn = sshSessions[sessionId];
const userId = ctx.currentActor()!;
if (!sessionId) {
return res.status(400).json({ error: "Session ID is required" });
}
if (!sshConn?.isConnected) {
return res.status(400).json({ error: "SSH connection not established" });
}
if (!verifySessionOwnership(sshConn, userId)) {
return res.status(403).json({ error: "Session access denied" });
}
if (!oldPath || !newPath) {
return res
.status(400)
.json({ error: "Old path and new path are required" });
}
sshConn.lastActive = Date.now();
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
const moveCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
const commandTimeout = setTimeout(() => {
if (!res.headersSent) {
res.status(408).json({
error: "Move operation timed out. SSH connection may be unstable.",
toast: {
type: "error",
message:
"Move operation timed out. SSH connection may be unstable.",
},
});
}
}, 60000);
execChannel(sshConn, moveCommand, (err, stream) => {
if (err) {
clearTimeout(commandTimeout);
ctx.log.error("SSH moveItem error:", err);
if (!res.headersSent) {
return res.status(500).json({ error: err.message });
}
return;
}
let outputData = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
outputData += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
if (chunk.toString().includes("Permission denied")) {
ctx.log.error(`Permission denied moving: ${oldPath}`);
if (!res.headersSent) {
return res.status(403).json({
error: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
toast: {
type: "error",
message: `Permission denied: Cannot move ${oldPath}. Check file permissions.`,
},
});
}
return;
}
});
stream.on("close", (code) => {
clearTimeout(commandTimeout);
if (outputData.includes("SUCCESS")) {
if (!res.headersSent) {
res.json({
message: "Item moved successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item moved: ${oldPath} -> ${newPath}`,
},
});
}
return;
}
if (code !== 0) {
ctx.log.error(
`SSH moveItem command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
if (!res.headersSent) {
return res.status(500).json({
error: `Command failed: ${errorData}`,
toast: { type: "error", message: `Move failed: ${errorData}` },
});
}
return;
}
if (!res.headersSent) {
res.json({
message: "Item moved successfully",
oldPath,
newPath,
toast: {
type: "success",
message: `Item moved: ${oldPath} -> ${newPath}`,
},
});
}
});
stream.on("error", (streamErr) => {
clearTimeout(commandTimeout);
ctx.log.error("SSH moveItem stream error:", streamErr);
if (!res.headersSent) {
res.status(500).json({ error: `Stream error: ${streamErr.message}` });
}
});
});
});
}
@@ -0,0 +1,13 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
let current: PluginContext | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginCtx(ctx: PluginContext | null): void {
current = ctx;
}
export function pluginCtx(): PluginContext {
if (!current) throw new Error("The plugin is not active");
return current;
}
+147
View File
@@ -0,0 +1,147 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import {
promisifySftpMkdir,
promisifySftpReaddir,
promisifySftpStat,
promisifySftpUnlink,
promisifySftpRmdir,
} from "./sftp-promisify.js";
const DEFAULT_DIR_MODE = 0o755;
import type { SSHSession } from "./session.js";
/** What other plugins get from ctx.services.get("files.sftp", {userId}). */
export interface FilesService {
list: (
hostId: number,
path: string,
) => Promise<
Array<{ name: string; type: "file" | "directory" | "link"; size?: number }>
>;
read: (hostId: number, path: string) => Promise<string>;
write: (hostId: number, path: string, content: string) => Promise<void>;
stat: (
hostId: number,
path: string,
) => Promise<{ size: number; isDirectory: boolean; mtime: number }>;
delete: (hostId: number, path: string) => Promise<void>;
mkdir: (hostId: number, path: string) => Promise<void>;
}
/**
* A short-lived SFTP connection for a service call, outside the browse
* session map: a caller reaches this from any request the acting user makes,
* not necessarily one that has an open file-manager tab.
*/
async function withServiceSftp<T>(
ctx: PluginContext,
hostId: number,
fn: (sftp: import("ssh2").SFTPWrapper) => Promise<T>,
): Promise<T> {
return ctx.ssh.withConnection<T, import("ssh2").Client>(
hostId,
{
pool: "file-manager-service",
purpose: "file-manager",
profile: "session",
},
(client) =>
new Promise<T>((resolve, reject) => {
client.sftp(async (err, sftp) => {
if (err) return reject(err);
try {
resolve(await fn(sftp));
} catch (error) {
reject(error);
}
});
}),
);
}
export function createFilesService(
ctx: PluginContext,
deps: {
sshSessions: Record<string, SSHSession>;
verifySessionOwnership: (session: SSHSession, userId: string) => boolean;
},
): FilesService {
void deps;
return {
async list(hostId, path) {
return withServiceSftp(ctx, hostId, async (sftp) => {
const entries = await promisifySftpReaddir(sftp, path);
return entries
.filter((e) => e.filename !== "." && e.filename !== "..")
.map((e) => ({
name: e.filename,
type: e.attrs.isDirectory()
? ("directory" as const)
: e.attrs.isSymbolicLink()
? ("link" as const)
: ("file" as const),
size: e.attrs.isDirectory() ? undefined : e.attrs.size,
}));
});
},
async read(hostId, path) {
return withServiceSftp(
ctx,
hostId,
(sftp) =>
new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = [];
const stream = sftp.createReadStream(path);
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
stream.on("end", () =>
resolve(Buffer.concat(chunks).toString("utf8")),
);
stream.on("error", reject);
}),
);
},
async write(hostId, path, content) {
await withServiceSftp(
ctx,
hostId,
(sftp) =>
new Promise<void>((resolve, reject) => {
const stream = sftp.createWriteStream(path, { flags: "w" });
stream.on("close", () => resolve());
stream.on("error", reject);
stream.end(content, "utf8");
}),
);
},
async stat(hostId, path) {
return withServiceSftp(ctx, hostId, async (sftp) => {
const stats = await promisifySftpStat(sftp, path);
return {
size: stats.size,
isDirectory: stats.isDirectory(),
mtime: stats.mtime,
};
});
},
async delete(hostId, path) {
await withServiceSftp(ctx, hostId, async (sftp) => {
const stats = await promisifySftpStat(sftp, path);
if (stats.isDirectory()) {
await promisifySftpRmdir(sftp, path);
} else {
await promisifySftpUnlink(sftp, path);
}
});
},
async mkdir(hostId, path) {
await withServiceSftp(ctx, hostId, (sftp) =>
promisifySftpMkdir(sftp, path, DEFAULT_DIR_MODE),
);
},
};
}
+282
View File
@@ -0,0 +1,282 @@
import type { Client as SSHClient } from "ssh2";
// Serializes SSH channel open requests so only one channel negotiation is
// in-flight at a time per session. Once the channel is established the slot
// is released immediately so the next open can proceed; the channels
// themselves remain open concurrently.
export class ChannelOpenSerializer {
private tail: Promise<void> = Promise.resolve();
run<T>(action: () => Promise<T>): Promise<T> {
const next = this.tail.then(
() => action(),
() => action(),
);
this.tail = next.then(
() => {},
() => {},
);
return next;
}
}
export interface SSHSession {
client: SSHClient;
isConnected: boolean;
lastActive: number;
timeout?: NodeJS.Timeout;
activeOperations: number;
sudoPassword?: string;
sftp?: import("ssh2").SFTPWrapper;
sftpPending?: Promise<import("ssh2").SFTPWrapper>;
channelOpener: ChannelOpenSerializer;
poolKey?: string;
userId?: string;
hostId?: number;
ip?: string;
port?: number;
username?: string;
transferDedicated?: boolean;
transferId?: string;
browseSessionId?: string;
scpLegacy?: boolean;
}
export interface PendingTOTPSession {
client: SSHClient;
finish: (responses: string[]) => void;
config: import("ssh2").ConnectConfig;
createdAt: number;
sessionId: string;
hostId?: number;
ip?: string;
port?: number;
username?: string;
userId?: string;
prompts?: Array<{ prompt: string; echo: boolean }>;
totpPromptIndex?: number;
resolvedPassword?: string;
totpAttempts: number;
isBrowserSignIn?: boolean;
}
export function execWithSudo(
session: SSHSession,
command: string,
sudoPassword: string,
): Promise<{ stdout: string; stderr: string; code: number }> {
return execWithSudoBuffer(session, command, sudoPassword).then((result) => ({
stdout: result.stdout.toString("utf8"),
stderr: result.stderr,
code: result.code,
}));
}
export function execWithSudoBuffer(
session: SSHSession,
command: string,
sudoPassword: string,
maxStdoutBytes?: number,
): Promise<{
stdout: Buffer;
stderr: string;
code: number;
exceededLimit?: boolean;
}> {
return new Promise((resolve) => {
const sudoCommand = `sudo -S -p '' ${command} 2>&1`;
execChannel(session, sudoCommand, (err, stream) => {
if (err) {
resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
return;
}
const stdoutChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderr = "";
let settled = false;
const finish = (
code: number,
extra: { exceededLimit?: boolean } = {},
) => {
if (settled) return;
settled = true;
resolve({
stdout: Buffer.concat(stdoutChunks),
stderr,
code,
...extra,
});
};
stream.on("data", (chunk: Buffer) => {
if (settled) return;
stdoutBytes += chunk.length;
if (maxStdoutBytes !== undefined && stdoutBytes > maxStdoutBytes) {
stderr = `Command output exceeds ${maxStdoutBytes} bytes`;
finish(1, { exceededLimit: true });
stream.close();
return;
}
stdoutChunks.push(chunk);
});
stream.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
stream.on("close", (code: number) => {
if (settled) return;
let stdout = Buffer.concat(stdoutChunks);
const sudoPromptMatch = stdout
.toString("utf8", 0, Math.min(stdout.length, 256))
.match(/^\[sudo\] password for .+?:\s*/);
if (sudoPromptMatch) {
stdout = stdout.subarray(Buffer.byteLength(sudoPromptMatch[0]));
}
settled = true;
resolve({ stdout, stderr, code: code || 0 });
});
stream.on("error", (streamErr: Error) => {
stderr = streamErr.message;
finish(1);
});
stream.write(`${sudoPassword}\n`);
});
});
}
export function execBuffer(
session: SSHSession,
command: string,
maxStdoutBytes?: number,
): Promise<{
stdout: Buffer;
stderr: string;
code: number;
exceededLimit?: boolean;
}> {
return new Promise((resolve) => {
execChannel(session, command, (err, stream) => {
if (err) {
resolve({ stdout: Buffer.alloc(0), stderr: err.message, code: 1 });
return;
}
const stdoutChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderr = "";
let settled = false;
const finish = (
code: number,
extra: { exceededLimit?: boolean } = {},
) => {
if (settled) return;
settled = true;
resolve({
stdout: Buffer.concat(stdoutChunks),
stderr,
code,
...extra,
});
};
stream.on("data", (chunk: Buffer) => {
if (settled) return;
stdoutBytes += chunk.length;
if (maxStdoutBytes !== undefined && stdoutBytes > maxStdoutBytes) {
stderr = `Command output exceeds ${maxStdoutBytes} bytes`;
finish(1, { exceededLimit: true });
stream.close();
return;
}
stdoutChunks.push(chunk);
});
stream.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
stream.on("close", (code: number) => finish(code || 0));
stream.on("error", (streamErr: Error) => {
stderr = stderr || streamErr.message;
finish(1);
});
});
});
}
export function getSessionSftp(
session: SSHSession,
): Promise<import("ssh2").SFTPWrapper> {
if (session.sftp) {
return Promise.resolve(session.sftp);
}
if (session.sftpPending) {
return session.sftpPending;
}
const openOnce = (): Promise<import("ssh2").SFTPWrapper> =>
session.channelOpener.run(
() =>
new Promise<import("ssh2").SFTPWrapper>((resolve, reject) => {
session.client.sftp((err, sftp) => {
if (err) return reject(err);
session.sftp = sftp;
sftp.on("error", () => {
session.sftp = undefined;
});
sftp.on("close", () => {
session.sftp = undefined;
});
resolve(sftp);
});
}),
);
session.sftpPending = openOnce()
.catch((err: Error) => {
const isChannelFailure =
err.message.toLowerCase().includes("channel open failure") ||
err.message.toLowerCase().includes("open failed");
if (isChannelFailure) {
return new Promise<import("ssh2").SFTPWrapper>((resolve, reject) =>
setTimeout(() => openOnce().then(resolve, reject), 500),
);
}
return Promise.reject(err);
})
.finally(() => {
session.sftpPending = undefined;
});
return session.sftpPending;
}
export function execChannel(
session: SSHSession,
command: string,
callback: (
err: Error | undefined,
stream: import("ssh2").ClientChannel,
) => void,
): void {
session.channelOpener
.run(
() =>
new Promise<import("ssh2").ClientChannel>((resolve, reject) => {
session.client.exec(command, (err, stream) => {
if (err) return reject(err);
resolve(stream);
});
}),
)
.then(
(stream) => callback(undefined, stream),
(err: Error) => callback(err, undefined as never),
);
}
@@ -0,0 +1,120 @@
type SFTPWrapper = import("ssh2").SFTPWrapper;
export const SFTP_OPEN_READ = 0x00000001;
export const SFTP_OPEN_WRITE = 0x00000002 | 0x00000008 | 0x00000010;
export const SFTP_OPEN_WRITE_RESUME = 0x00000001 | 0x00000002 | 0x00000008;
export function promisifySftpStat(
sftp: SFTPWrapper,
path: string,
): Promise<import("ssh2").Stats> {
return new Promise((resolve, reject) => {
sftp.stat(path, (err, stats) => {
if (err) reject(err);
else resolve(stats);
});
});
}
export function promisifySftpUnlink(
sftp: SFTPWrapper,
path: string,
): Promise<void> {
return new Promise((resolve, reject) => {
sftp.unlink(path, (err) => {
if (err) reject(err);
else resolve();
});
});
}
export function promisifySftpRmdir(
sftp: SFTPWrapper,
path: string,
): Promise<void> {
return new Promise((resolve, reject) => {
sftp.rmdir(path, (err) => {
if (err) reject(err);
else resolve();
});
});
}
export function promisifySftpMkdir(
sftp: SFTPWrapper,
path: string,
mode: number,
): Promise<void> {
return new Promise((resolve, reject) => {
sftp.mkdir(path, { mode }, (err) => {
if (err && (err as NodeJS.ErrnoException).code !== "EEXIST") {
reject(err);
} else {
resolve();
}
});
});
}
export function promisifySftpChmod(
sftp: SFTPWrapper,
path: string,
mode: number,
): Promise<void> {
return new Promise((resolve, reject) => {
sftp.chmod(path, mode, (err) => {
if (err) reject(err);
else resolve();
});
});
}
export function promisifySftpReaddir(
sftp: SFTPWrapper,
path: string,
): Promise<Array<{ filename: string; attrs: import("ssh2").Stats }>> {
return new Promise((resolve, reject) => {
sftp.readdir(path, (err, list) => {
if (err) reject(err);
else resolve(list);
});
});
}
export function promisifySftpOpen(
sftp: SFTPWrapper,
path: string,
flags: number,
mode: number,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
sftp.open(path, flags, mode, (err, handle) => {
if (err) reject(err);
else resolve(handle);
});
});
}
export function promisifySftpClose(
sftp: SFTPWrapper,
handle: Buffer,
): Promise<void> {
return new Promise((resolve, reject) => {
sftp.close(handle, (err) => {
if (err) reject(err);
else resolve();
});
});
}
export function promisifySftpFstat(
sftp: SFTPWrapper,
handle: Buffer,
): Promise<import("ssh2").Stats> {
return new Promise((resolve, reject) => {
sftp.fstat(handle, (err, stats) => {
if (err) reject(err);
else resolve(stats);
});
});
}
+79
View File
@@ -0,0 +1,79 @@
import type { Client, ConnectConfig } from "ssh2";
import type {
PluginSsh,
PluginSshAuthOutcome,
PluginSshConnection,
PluginSshConnectOptions,
PluginSshHost,
} from "@termix/plugin-sdk/backend";
let current: PluginSsh | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginSsh(ssh: PluginSsh | null): void {
current = ssh;
}
export function pluginSsh(): PluginSsh {
if (!current) throw new Error("The plugin is not active");
return current;
}
/** withConnection with ssh2's Client type filled in. */
export function withSshConnection<T>(
host: number | PluginSshHost,
options: PluginSshConnectOptions & { pool: string },
fn: (client: Client) => Promise<T>,
): Promise<T> {
return pluginSsh().withConnection<T, Client>(host, options, fn);
}
/** connect with ssh2's Client type filled in. */
export function connectSsh(
host: number | PluginSshHost,
options?: PluginSshConnectOptions,
): Promise<PluginSshConnection<Client>> {
return pluginSsh().connect<Client>(host, options);
}
/**
* Lower-level primitives for the interactive /connect route, which drives its
* own ssh2 Client through the TOTP and browser sign-in parking flow rather than letting
* ctx.ssh.connect finish the handshake itself.
*/
export function prepareSsh(
host: PluginSshHost,
options: {
purpose?: "file-manager" | "file-transfer";
client: Client;
serverHostId?: number;
log?: (level: "info" | "warning" | "error", message: string) => void;
},
): Promise<{ config: ConnectConfig; outcome: PluginSshAuthOutcome }> {
return pluginSsh().prepare(host, options) as Promise<{
config: ConnectConfig;
outcome: PluginSshAuthOutcome;
}>;
}
export function openSshTransport(
host: PluginSshHost,
config: ConnectConfig,
): Promise<{ jumpClient: Client | null; via: string }> {
return pluginSsh().openTransport(
host,
config as unknown as Record<string, unknown>,
) as Promise<{
jumpClient: Client | null;
via: string;
}>;
}
export const classifyKeyboardInteractive: PluginSsh["classifyKeyboardInteractive"] =
(round, host) => pluginSsh().classifyKeyboardInteractive(round, host);
export const autoResponses: PluginSsh["autoResponses"] = (prompts, password) =>
pluginSsh().autoResponses(prompts, password);
export const requiresSecret: PluginSsh["requiresSecret"] = (authType) =>
pluginSsh().requiresSecret(authType);
+105
View File
@@ -0,0 +1,105 @@
import {
adoptLegacyTable,
defineTable,
id,
refHost,
refUser,
text,
timestamp,
} from "@termix/plugin-sdk/db";
/**
* The paths and hosts a user recently opened in the file manager.
*
* Adopted from core's file_manager_recent, so the column and index names are
* the legacy ones: the rename carries the existing rows and index across.
*/
export const recent = adoptLegacyTable(
"file_manager_recent",
defineTable(
"recent",
{
id: id(),
userId: refUser(),
hostId: refHost(),
name: text().notNull(),
path: text().notNull(),
lastOpened: timestamp().notNull().defaultNow(),
},
{
indexes: [
{ name: "idx_file_manager_recent_user", columns: ["userId", "hostId"] },
],
},
),
);
/** Paths a user pinned for quick access on a host. */
export const pinned = adoptLegacyTable(
"file_manager_pinned",
defineTable(
"pinned",
{
id: id(),
userId: refUser(),
hostId: refHost(),
name: text().notNull(),
path: text().notNull(),
pinnedAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{
name: "idx_file_manager_pinned_user",
columns: ["userId", "hostId"],
},
],
},
),
);
/** Named folder shortcuts a user saved on a host. */
export const shortcuts = adoptLegacyTable(
"file_manager_shortcuts",
defineTable(
"shortcuts",
{
id: id(),
userId: refUser(),
hostId: refHost(),
name: text().notNull(),
path: text().notNull(),
createdAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{
name: "idx_file_manager_shortcuts_user",
columns: ["userId", "hostId"],
},
],
},
),
);
/** Recently used destinations for host-to-host transfers. */
export const transferRecent = adoptLegacyTable(
"transfer_recent",
defineTable(
"transfer_recent",
{
id: id(),
userId: refUser(),
sourceHostId: refHost(),
destHostId: refHost(),
destPath: text().notNull(),
destPathLabel: text().notNull(),
lastUsed: timestamp().notNull().defaultNow(),
},
{
indexes: [{ name: "idx_transfer_recent_user", columns: ["userId"] }],
},
),
);
export const tables = [recent, pinned, shortcuts, transferRecent];
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,52 @@
export class TransferCancelledError extends Error {
constructor() {
super("Transfer cancelled");
this.name = "TransferCancelledError";
}
}
export class TransferStalledError extends Error {
readonly byteOffset?: number;
readonly segmentIndex?: number;
constructor(byteOffset?: number, segmentIndex?: number) {
const pos = byteOffset !== undefined ? ` at byte offset ${byteOffset}` : "";
const seg = segmentIndex !== undefined ? ` (segment ${segmentIndex})` : "";
super(`Transfer stalled: no data moved for 45 seconds${pos}${seg}`);
this.name = "TransferStalledError";
this.byteOffset = byteOffset;
this.segmentIndex = segmentIndex;
}
}
export class TransferConnectionLostError extends Error {
constructor(message = "Transfer SSH connection lost") {
super(message);
this.name = "TransferConnectionLostError";
}
}
export function isRecoverableTransferConnectionError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("no response from server") ||
msg.includes("connection lost") ||
msg.includes("not connected") ||
msg.includes("econnreset") ||
msg.includes("econnrefused") ||
msg.includes("etimedout") ||
msg.includes("socket hang up") ||
msg.includes("protocol error") ||
msg.includes("connection closed") ||
msg.includes("channel open failure")
);
}
export function isRecoverableTransferError(err: unknown): boolean {
return (
err instanceof TransferStalledError ||
err instanceof TransferConnectionLostError ||
isRecoverableTransferConnectionError(err)
);
}
@@ -0,0 +1,59 @@
import { networkInterfaces } from "os";
import { normalizeSftpPath } from "./transfer-paths.js";
let cachedLocalAddresses: Set<string> | null = null;
export function normalizeHostAddress(host: string): string {
const trimmed = host.trim().toLowerCase();
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
return trimmed.slice(1, -1);
}
return trimmed.split(":")[0] ?? trimmed;
}
function getLocalAddresses(): Set<string> {
if (cachedLocalAddresses) return cachedLocalAddresses;
const addresses = new Set(["127.0.0.1", "::1", "localhost"]);
for (const ifaces of Object.values(networkInterfaces())) {
if (!ifaces) continue;
for (const iface of ifaces) {
if (!iface.internal && iface.family === "IPv4") {
addresses.add(iface.address.toLowerCase());
}
}
}
cachedLocalAddresses = addresses;
return addresses;
}
export function isLocalSshEndpoint(ip?: string): boolean {
if (!ip) return false;
const bare = normalizeHostAddress(ip);
if (bare === "localhost" || bare === "127.0.0.1" || bare === "::1") {
return true;
}
return getLocalAddresses().has(bare);
}
export function escapeShell(s: string): string {
return s.replace(/'/g, "'\"'\"'");
}
export function isRootOnlyPath(path: string): boolean {
const normalized = normalizeSftpPath(path);
return (
normalized === "/" ||
/^\/[A-Za-z]:$/.test(normalized) ||
/^[A-Za-z]:$/.test(normalized)
);
}
export function isPermissionError(err: Error): boolean {
const msg = err.message.toLowerCase();
return (
msg.includes("permission denied") ||
msg.includes("eacces") ||
msg.includes("access denied")
);
}
@@ -0,0 +1,54 @@
import { createHash } from "node:crypto";
type SFTPWrapper = import("ssh2").SFTPWrapper;
export interface TransferIntegrityResult {
algorithm: "sha256";
digest: string;
}
export async function hashSftpFile(
sftp: SFTPWrapper,
path: string,
shouldAbort: () => boolean = () => false,
createAbortError: () => Error = () => new Error("Transfer cancelled"),
): Promise<string> {
const stream = sftp.createReadStream(path);
const hash = createHash("sha256");
try {
for await (const chunk of stream) {
if (shouldAbort()) {
const error = createAbortError();
stream.destroy(error);
throw error;
}
hash.update(chunk);
}
} finally {
stream.destroy();
}
if (shouldAbort()) throw createAbortError();
return hash.digest("hex");
}
export async function verifySftpFileIntegrity(
sourceSftp: SFTPWrapper,
destSftp: SFTPWrapper,
sourcePath: string,
destPath: string,
shouldAbort: () => boolean = () => false,
createAbortError: () => Error = () => new Error("Transfer cancelled"),
): Promise<TransferIntegrityResult> {
const [sourceDigest, destDigest] = await Promise.all([
hashSftpFile(sourceSftp, sourcePath, shouldAbort, createAbortError),
hashSftpFile(destSftp, destPath, shouldAbort, createAbortError),
]);
if (sourceDigest !== destDigest) {
throw new Error(`SHA-256 verification failed for ${sourcePath}`);
}
return { algorithm: "sha256", digest: sourceDigest };
}
@@ -0,0 +1,151 @@
export type TransferPlatform = "windows" | "unix";
/** OpenSSH SFTP on Windows commonly uses `/C:/...` or `C:/...` style paths. */
export function isWindowsSftpPath(path: string): boolean {
const normalized = path.replace(/\\/g, "/");
return /^[A-Za-z]:\//.test(normalized) || /^\/[A-Za-z]:\//.test(normalized);
}
export function inferPlatformFromPath(path: string): TransferPlatform | null {
if (isWindowsSftpPath(path)) return "windows";
if (path.startsWith("/") && !/^\/[A-Za-z]:/.test(path.replace(/\\/g, "/"))) {
return "unix";
}
return null;
}
export function normalizeSftpPath(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
if (!normalized) return path.startsWith("/") ? "/" : ".";
return normalized.replace(/([^:])\/+/g, "$1/");
}
export function basename(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
const idx = normalized.lastIndexOf("/");
if (idx < 0) return normalized;
return normalized.substring(idx + 1) || normalized;
}
export function dirname(path: string): string {
const normalized = path.replace(/\\/g, "/").replace(/\/+$/, "");
if (!normalized) return normalized;
const idx = normalized.lastIndexOf("/");
if (idx < 0) return normalized;
const parent = normalized.substring(0, idx);
if (!parent) return "/";
if (/^\/[A-Za-z]:$/.test(parent) || /^[A-Za-z]:$/.test(parent)) {
return parent;
}
return parent || "/";
}
export function joinPath(base: string, ...parts: string[]): string {
let result = base.replace(/\\/g, "/");
for (const part of parts) {
if (!part) continue;
const segment = part.replace(/\\/g, "/").replace(/^\/+/, "");
if (!segment) continue;
if (result.endsWith("/")) {
result += segment;
} else {
result = `${result}/${segment}`;
}
}
return normalizeSftpPath(result);
}
export interface PathSegments {
/** Drive root prefix, e.g. `/C:` or `C:`; empty for Unix absolute paths. */
root: string;
/** Path segments below the root (may be empty). */
segments: string[];
}
export function splitPathSegments(path: string): PathSegments {
const normalized = normalizeSftpPath(path);
const posixDrive = normalized.match(/^(\/[A-Za-z]:)(?:\/(.*))?$/);
if (posixDrive) {
return {
root: posixDrive[1],
segments: (posixDrive[2] ?? "").split("/").filter(Boolean),
};
}
const drive = normalized.match(/^([A-Za-z]:)(?:\/(.*))?$/);
if (drive) {
return {
root: drive[1],
segments: (drive[2] ?? "").split("/").filter(Boolean),
};
}
if (normalized.startsWith("/")) {
return {
root: "/",
segments: normalized.split("/").filter(Boolean),
};
}
return {
root: "",
segments: normalized.split("/").filter(Boolean),
};
}
export function buildPathFromSegments(
root: string,
segments: string[],
count: number,
): string {
const slice = segments.slice(0, count);
if (/^\/[A-Za-z]:$/.test(root)) {
return slice.length ? `${root}/${slice.join("/")}` : root;
}
if (/^[A-Za-z]:$/.test(root)) {
return slice.length ? `${root}/${slice.join("/")}` : root;
}
if (root === "/") {
return slice.length ? `/${slice.join("/")}` : "/";
}
if (root === "") {
return slice.join("/");
}
return slice.length ? `${root}/${slice.join("/")}` : root;
}
export function pathsOverlap(source: string, dest: string): boolean {
const norm = (p: string) => {
const n = normalizeSftpPath(p).toLowerCase();
return n || (isWindowsSftpPath(p) ? p : "/");
};
const s = norm(source);
const d = norm(dest);
const sep = "/";
return s === d || s.startsWith(`${d}${sep}`) || d.startsWith(`${s}${sep}`);
}
export function getWorkingDir(paths: string[]): string {
if (paths.length === 0) return "/";
const parents = paths.map((p) => dirname(p));
const first = parents[0];
if (parents.every((p) => p === first)) {
return first;
}
return first;
}
/** Convert an SFTP path to a local filesystem path when Termix runs on the dest host. */
export function sftpPathToLocalPath(sftpPath: string): string {
const normalized = sftpPath.replace(/\\/g, "/");
if (/^\/[A-Za-z]:\//.test(normalized)) {
return normalized.slice(1).replace(/\//g, "\\");
}
if (/^[A-Za-z]:\//.test(normalized)) {
return normalized.replace(/\//g, "\\");
}
return sftpPath;
}
@@ -0,0 +1,205 @@
import type { TransferPlatform } from "./transfer-paths.js";
import { deflateRawSync } from "node:zlib";
export type TransferMethodPreference = "auto" | "tar" | "item_sftp";
export interface TransferScanSummary {
fileCount: number;
totalBytes: number;
largestFileBytes: number;
/** Share of total bytes in likely incompressible file types (0–1). */
incompressibleRatio: number;
/** Share inferred from bounded content samples, when available. */
sampledIncompressibleRatio?: number;
}
const INCOMPRESSIBLE_EXT =
/\.(zip|gz|bz2|xz|7z|rar|tar|tgz|jpg|jpeg|png|gif|webp|mp3|mp4|mkv|avi|mov|wmv|iso|dmg|deb|rpm|pdf|db|sqlite|wasm|vmdk|qcow2)$/i;
const GB = 1024 * 1024 * 1024;
const MB = 1024 * 1024;
export function isLikelyIncompressiblePath(path: string): boolean {
return INCOMPRESSIBLE_EXT.test(path);
}
export function buildTransferScanSummary(
items: Array<{ sourcePath: string; size: number }>,
): TransferScanSummary {
let totalBytes = 0;
let largestFileBytes = 0;
let incompressibleBytes = 0;
for (const item of items) {
totalBytes += item.size;
largestFileBytes = Math.max(largestFileBytes, item.size);
if (isLikelyIncompressiblePath(item.sourcePath)) {
incompressibleBytes += item.size;
}
}
return {
fileCount: items.length,
totalBytes,
largestFileBytes,
incompressibleRatio: totalBytes > 0 ? incompressibleBytes / totalBytes : 0,
};
}
export function estimateIncompressibleSample(data: Buffer): boolean {
if (data.length === 0) return false;
return deflateRawSync(data, { level: 1 }).length / data.length >= 0.9;
}
function effectiveIncompressibleRatio(summary: TransferScanSummary): number {
return summary.sampledIncompressibleRatio ?? summary.incompressibleRatio;
}
/**
* Choose tar vs per-item SFTP for directory / multi-file transfers.
* Single-file stream transfers bypass this entirely.
*/
export function resolveArchiveTransferMethod(
preference: TransferMethodPreference,
summary: TransferScanSummary,
sourcePlatform: TransferPlatform,
destPlatform: TransferPlatform,
sourceHasTar: boolean,
destHasTar: boolean,
): "tar" | "item_sftp" {
if (sourcePlatform === "windows" || destPlatform === "windows") {
return "item_sftp";
}
if (preference === "item_sftp") {
return "item_sftp";
}
if (preference === "tar") {
return sourceHasTar && destHasTar ? "tar" : "item_sftp";
}
// auto
if (!sourceHasTar || !destHasTar) {
return "item_sftp";
}
const { fileCount, totalBytes, largestFileBytes } = summary;
const incompressibleRatio = effectiveIncompressibleRatio(summary);
if (fileCount === 0) {
return "item_sftp";
}
if (fileCount === 1 && largestFileBytes >= 2 * GB) {
return "item_sftp";
}
// Multi-item sets with a large file: tar only when data is likely compressible
// (mostly video/zip → per-file SFTP is simpler and avoids pack/unpack time).
if (
fileCount > 1 &&
largestFileBytes >= 2 * GB &&
totalBytes >= 500 * MB &&
incompressibleRatio < 0.7
) {
return "tar";
}
if (fileCount === 1 && largestFileBytes >= 5 * GB) {
return "item_sftp";
}
if (totalBytes >= 10 * GB && incompressibleRatio >= 0.5) {
return "item_sftp";
}
if (incompressibleRatio >= 0.85) {
return "item_sftp";
}
if (fileCount >= 100) {
return "tar";
}
if (fileCount >= 20 && totalBytes >= 50 * MB && incompressibleRatio < 0.5) {
return "tar";
}
if (fileCount > 5 && incompressibleRatio < 0.7) {
return "tar";
}
return "item_sftp";
}
export type ArchiveTransferReasonKey =
| "user_item_sftp"
| "user_tar"
| "tar_unavailable"
| "windows_host"
| "auto_multi_large"
| "auto_single_large_in_archive"
| "auto_many_incompressible"
| "auto_many_files"
| "auto_default";
/** i18n key suffix under transfer.methodReason.* */
export function getArchiveTransferReasonKey(
preference: TransferMethodPreference,
resolvedMethod: "tar" | "item_sftp",
summary: TransferScanSummary,
sourcePlatform: TransferPlatform,
destPlatform: TransferPlatform,
sourceHasTar: boolean,
destHasTar: boolean,
): ArchiveTransferReasonKey {
if (preference === "item_sftp") return "user_item_sftp";
if (preference === "tar") {
return sourceHasTar && destHasTar ? "user_tar" : "tar_unavailable";
}
if (sourcePlatform === "windows" || destPlatform === "windows") {
return "windows_host";
}
if (!sourceHasTar || !destHasTar) {
return "tar_unavailable";
}
const { fileCount, totalBytes, largestFileBytes } = summary;
const incompressibleRatio = effectiveIncompressibleRatio(summary);
if (
fileCount > 1 &&
largestFileBytes >= 2 * GB &&
totalBytes >= 500 * MB &&
incompressibleRatio < 0.7
) {
return "auto_multi_large";
}
if (fileCount === 1 && largestFileBytes >= 2 * GB) {
return "auto_single_large_in_archive";
}
if (totalBytes >= 10 * GB && incompressibleRatio >= 0.5) {
return "auto_many_incompressible";
}
if (incompressibleRatio >= 0.85) {
return "auto_many_incompressible";
}
if (
fileCount >= 100 ||
(fileCount >= 20 && totalBytes >= 50 * MB && incompressibleRatio < 0.5) ||
(fileCount > 5 && incompressibleRatio < 0.7)
) {
return "auto_many_files";
}
if (resolvedMethod === "tar") {
return "auto_many_files";
}
return "auto_default";
}
@@ -0,0 +1,89 @@
import { basename, joinPath } from "./transfer-paths.js";
import {
SFTP_OPEN_READ,
promisifySftpClose,
promisifySftpOpen,
promisifySftpReaddir,
promisifySftpStat,
} from "./sftp-promisify.js";
type SFTPWrapper = import("ssh2").SFTPWrapper;
export interface FileWorkItem {
sourcePath: string;
destPath: string;
mode: number;
size: number;
}
export async function collectFileWorkItems(
sftp: SFTPWrapper,
sourcePath: string,
destRoot: string,
destBaseName?: string,
): Promise<FileWorkItem[]> {
const stats = await promisifySftpStat(sftp, sourcePath);
const name = destBaseName ?? basename(sourcePath);
const destPath = joinPath(destRoot, name);
if (stats.isFile()) {
return [
{
sourcePath,
destPath,
mode: stats.mode & 0o7777,
size: stats.size,
},
];
}
if (!stats.isDirectory()) {
return [];
}
const items: FileWorkItem[] = [];
const walk = async (srcDir: string, dstDir: string) => {
const entries = await promisifySftpReaddir(sftp, srcDir);
for (const entry of entries) {
if (entry.filename === "." || entry.filename === "..") continue;
const srcChild = joinPath(srcDir, entry.filename);
const dstChild = joinPath(dstDir, entry.filename);
if (entry.attrs.isDirectory()) {
await walk(srcChild, dstChild);
} else if (entry.attrs.isFile()) {
items.push({
sourcePath: srcChild,
destPath: dstChild,
mode: entry.attrs.mode & 0o7777,
size: entry.attrs.size,
});
}
}
};
await walk(sourcePath, destPath);
return items;
}
export async function readSftpSample(
sftp: SFTPWrapper,
path: string,
fileSize: number,
): Promise<Buffer> {
const sampleSize = Math.min(64 * 1024, fileSize);
const position = Math.max(0, Math.floor((fileSize - sampleSize) / 2));
const handle = await promisifySftpOpen(sftp, path, SFTP_OPEN_READ, 0o666);
try {
const buffer = Buffer.alloc(sampleSize);
const bytesRead = await new Promise<number>((resolve, reject) => {
sftp.read(handle, buffer, 0, sampleSize, position, (err, count) => {
if (err) reject(err);
else resolve(count);
});
});
return buffer.subarray(0, bytesRead);
} finally {
await promisifySftpClose(sftp, handle).catch(() => {});
}
}
@@ -0,0 +1,40 @@
export const SFTP_XFER_SEGMENT_SIZE = 256 * 1024 * 1024;
export const DEFAULT_PARALLEL_SEGMENT_COUNT = 2;
export const MAX_PARALLEL_SEGMENT_COUNT = 8;
export interface SegmentCopyJob {
offset: number;
length: number;
segmentIndex: number;
}
export function clampParallelSegmentCount(value?: number): number {
const n = value ?? DEFAULT_PARALLEL_SEGMENT_COUNT;
return Math.max(1, Math.min(MAX_PARALLEL_SEGMENT_COUNT, Math.floor(n)));
}
export function buildSegmentCopyJobs(
fileSize: number,
initialOffset: number,
destResumeSize: number,
): SegmentCopyJob[] {
const jobs: SegmentCopyJob[] = [];
for (
let offset = initialOffset;
offset < fileSize;
offset += SFTP_XFER_SEGMENT_SIZE
) {
const length = Math.min(SFTP_XFER_SEGMENT_SIZE, fileSize - offset);
const segmentIndex = Math.floor(offset / SFTP_XFER_SEGMENT_SIZE);
if (destResumeSize >= offset + length) {
continue;
}
const start = destResumeSize > offset ? destResumeSize : offset;
jobs.push({
offset: start,
length: offset + length - start,
segmentIndex,
});
}
return jobs;
}
@@ -0,0 +1,74 @@
import {
buildPathFromSegments,
joinPath,
normalizeSftpPath,
splitPathSegments,
} from "./transfer-paths.js";
import { isRootOnlyPath } from "./transfer-host-utils.js";
import {
promisifySftpMkdir,
promisifySftpReaddir,
promisifySftpRmdir,
promisifySftpStat,
promisifySftpUnlink,
} from "./sftp-promisify.js";
type SFTPWrapper = import("ssh2").SFTPWrapper;
export async function ensureDirectoryTreeSftp(
sftp: SFTPWrapper,
dirPath: string,
created: Set<string> = new Set(),
): Promise<void> {
const normalized = normalizeSftpPath(dirPath);
if (!normalized || isRootOnlyPath(normalized)) return;
const { root, segments } = splitPathSegments(normalized);
if (segments.length === 0) return;
for (let i = 0; i < segments.length; i++) {
const current = buildPathFromSegments(root, segments, i + 1);
if (created.has(current)) continue;
try {
await promisifySftpMkdir(sftp, current, 0o755);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code !== "EEXIST") {
try {
const stats = await promisifySftpStat(sftp, current);
if (!stats.isDirectory()) throw err;
} catch {
throw err;
}
}
}
created.add(current);
}
}
export async function deletePathSftp(
sftp: SFTPWrapper,
path: string,
): Promise<void> {
let stats: import("ssh2").Stats;
try {
stats = await promisifySftpStat(sftp, path);
} catch {
return;
}
if (stats.isDirectory()) {
const entries = await promisifySftpReaddir(sftp, path);
for (const entry of entries) {
if (entry.filename === "." || entry.filename === "..") continue;
await deletePathSftp(sftp, joinPath(path, entry.filename));
}
await promisifySftpRmdir(sftp, path);
return;
}
if (stats.isFile()) {
await promisifySftpUnlink(sftp, path);
}
}
@@ -0,0 +1,157 @@
import { performance } from "node:perf_hooks";
const TRANSFER_PROGRESS_INTERVAL_MS = 200;
export type TransferHopId =
"source_read" | "dest_sftp_write" | "dest_local_write";
export interface TransferHopMetrics {
id: TransferHopId;
bytes: number;
/** Wall-clock span from first I/O on this hop to last I/O complete. */
spanMs: number;
mbPerSec: number;
}
export interface TransferTimings {
prepareDestMs?: number;
compressMs?: number;
transferMs?: number;
extractMs?: number;
verifyMs?: number;
directBenchmarkMs?: number;
relayBenchmarkMs?: number;
sourceDeleteMs?: number;
totalMs?: number;
transferBytes?: number;
endToEndMbPerSec?: number;
hops?: TransferHopMetrics[];
}
export function elapsedMs(start: number): number {
return Date.now() - start;
}
export function computeTransferMbPerSec(
bytes: number,
ms: number,
): number | undefined {
if (ms <= 0 || bytes <= 0) return undefined;
return ((bytes / ms) * 1000) / (1024 * 1024);
}
export interface HopWallClock {
firstAt: number | null;
lastAt: number | null;
}
export function createHopWallClock(): HopWallClock {
return { firstAt: null, lastAt: null };
}
export function noteHopStart(
clock: HopWallClock,
t: number = performance.now(),
): void {
if (clock.firstAt === null) clock.firstAt = t;
}
export function noteHopEnd(
clock: HopWallClock,
t: number = performance.now(),
): void {
clock.lastAt = t;
}
export function hopSpanMs(clock: HopWallClock): number {
if (clock.firstAt === null || clock.lastAt === null) return 0;
return Math.max(0, clock.lastAt - clock.firstAt);
}
export function createThrottledProgress(onProgress?: (bytes: number) => void) {
let pending = 0;
let lastFlush = 0;
const flush = () => {
if (pending > 0) {
onProgress?.(pending);
pending = 0;
lastFlush = Date.now();
}
};
return {
add(bytes: number) {
pending += bytes;
const now = Date.now();
if (now - lastFlush >= TRANSFER_PROGRESS_INTERVAL_MS) {
flush();
}
},
flush,
};
}
export interface PipelinedXferStats {
bytes: number;
sourceReadSpanMs: number;
destWriteSpanMs: number;
destWriteKind: "sftp" | "local";
}
export function createEmptyXferStats(): PipelinedXferStats {
return {
bytes: 0,
sourceReadSpanMs: 0,
destWriteSpanMs: 0,
destWriteKind: "sftp",
};
}
export function mergeXferStats(
target: PipelinedXferStats,
source: PipelinedXferStats,
): void {
target.bytes += source.bytes;
target.sourceReadSpanMs += source.sourceReadSpanMs;
target.destWriteSpanMs += source.destWriteSpanMs;
target.destWriteKind = source.destWriteKind;
}
export function buildTransferHopTimings(
stats: PipelinedXferStats,
transferMs: number,
): Pick<TransferTimings, "transferBytes" | "endToEndMbPerSec" | "hops"> {
const hops: TransferHopMetrics[] = [];
const sourceRate = computeTransferMbPerSec(
stats.bytes,
stats.sourceReadSpanMs,
);
if (sourceRate !== undefined) {
hops.push({
id: "source_read",
bytes: stats.bytes,
spanMs: stats.sourceReadSpanMs,
mbPerSec: sourceRate,
});
}
const destHopId: TransferHopId =
stats.destWriteKind === "local" ? "dest_local_write" : "dest_sftp_write";
const destRate = computeTransferMbPerSec(stats.bytes, stats.destWriteSpanMs);
if (destRate !== undefined) {
hops.push({
id: destHopId,
bytes: stats.bytes,
spanMs: stats.destWriteSpanMs,
mbPerSec: destRate,
});
}
return {
transferBytes: stats.bytes,
endToEndMbPerSec: computeTransferMbPerSec(stats.bytes, transferMs),
hops,
};
}
@@ -0,0 +1,389 @@
import { createHash } from "node:crypto";
export interface TransferPerformanceProfile {
throughputBps: number;
failureRate: number;
samples: number;
preferredLanes: number;
pipelineConcurrency: number;
updatedAt: number;
}
export interface TransferTuning {
parallelSegmentCount: number;
pipelineConcurrency: number;
}
export interface DirectRouteProfile {
directMs: number;
relayMs: number;
failureRate: number;
benchmarkSamples: number;
outcomeSamples: number;
benchmarkedAt: number;
cooldownUntil?: number;
updatedAt: number;
}
const MB = 1024 * 1024;
const PROFILE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const MAX_PROFILES = 128;
const STORE_VERSION = 1;
const profiles = new Map<string, TransferPerformanceProfile>();
const directRoutes = new Map<string, DirectRouteProfile>();
/** Where the learned profiles live: ctx.kv, set on activate. */
export interface TransferProfileStore {
read: () => Promise<unknown>;
write: (value: PersistedProfiles) => Promise<void>;
}
let store: TransferProfileStore | null = null;
let loadedStore: TransferProfileStore | null = null;
let loadPromise: Promise<void> | undefined;
export function setTransferProfileStore(
next: TransferProfileStore | null,
): void {
store = next;
loadedStore = null;
loadPromise = undefined;
profiles.clear();
directRoutes.clear();
}
let persistTimer: ReturnType<typeof setTimeout> | undefined;
let persistPromise = Promise.resolve();
export interface PersistedProfiles {
version: typeof STORE_VERSION;
profiles: Record<string, TransferPerformanceProfile>;
directRoutes: Record<string, DirectRouteProfile>;
}
function validDirectRoute(value: unknown): value is DirectRouteProfile {
if (!value || typeof value !== "object") return false;
const profile = value as Partial<DirectRouteProfile>;
return (
Number.isFinite(profile.directMs) &&
Number(profile.directMs) > 0 &&
Number.isFinite(profile.relayMs) &&
Number(profile.relayMs) > 0 &&
Number.isFinite(profile.failureRate) &&
Number(profile.failureRate) >= 0 &&
Number(profile.failureRate) <= 1 &&
Number.isFinite(profile.benchmarkSamples) &&
Number(profile.benchmarkSamples) > 0 &&
Number.isFinite(profile.outcomeSamples) &&
Number(profile.outcomeSamples) >= 0 &&
Number.isFinite(profile.benchmarkedAt) &&
(profile.cooldownUntil === undefined ||
Number.isFinite(profile.cooldownUntil)) &&
Number.isFinite(profile.updatedAt)
);
}
function profileId(key: string): string {
return createHash("sha256").update(key).digest("hex");
}
function validProfile(value: unknown): value is TransferPerformanceProfile {
if (!value || typeof value !== "object") return false;
const profile = value as Partial<TransferPerformanceProfile>;
return (
Number.isFinite(profile.throughputBps) &&
Number(profile.throughputBps) >= 0 &&
Number.isFinite(profile.failureRate) &&
Number(profile.failureRate) >= 0 &&
Number(profile.failureRate) <= 1 &&
Number.isFinite(profile.samples) &&
Number(profile.samples) > 0 &&
Number.isFinite(profile.preferredLanes) &&
Number.isFinite(profile.pipelineConcurrency) &&
Number.isFinite(profile.updatedAt)
);
}
function trimProfiles(now = Date.now()): void {
for (const [key, profile] of profiles) {
if (now - profile.updatedAt > PROFILE_TTL_MS) profiles.delete(key);
}
const recent = [...profiles.entries()]
.sort(([, a], [, b]) => b.updatedAt - a.updatedAt)
.slice(0, MAX_PROFILES);
profiles.clear();
for (const [key, profile] of recent) profiles.set(key, profile);
for (const [key, profile] of directRoutes) {
if (now - profile.updatedAt > PROFILE_TTL_MS) directRoutes.delete(key);
}
const recentRoutes = [...directRoutes.entries()]
.sort(([, a], [, b]) => b.updatedAt - a.updatedAt)
.slice(0, MAX_PROFILES);
directRoutes.clear();
for (const [key, profile] of recentRoutes) directRoutes.set(key, profile);
}
async function persistProfiles(): Promise<void> {
trimProfiles();
if (!store) return;
try {
await store.write({
version: STORE_VERSION,
profiles: Object.fromEntries(profiles),
directRoutes: Object.fromEntries(directRoutes),
});
} catch {
// Learning data is a nicety; a failed write must not affect transfers.
}
}
function queuePersist(): void {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
persistTimer = undefined;
persistPromise = persistPromise.then(persistProfiles);
}, 250);
persistTimer.unref?.();
}
export async function initializeTransferProfiles(): Promise<void> {
if (loadPromise && loadedStore === store) return loadPromise;
loadedStore = store;
const current = store;
loadPromise = (async () => {
profiles.clear();
directRoutes.clear();
if (!current) return;
try {
const parsed = ((await current.read()) ??
{}) as Partial<PersistedProfiles>;
if (parsed.version !== STORE_VERSION || !parsed.profiles) return;
for (const [key, profile] of Object.entries(parsed.profiles)) {
if (/^[a-f0-9]{64}$/.test(key) && validProfile(profile)) {
profiles.set(key, profile);
}
}
for (const [key, profile] of Object.entries(parsed.directRoutes ?? {})) {
if (/^[a-f0-9]{64}$/.test(key) && validDirectRoute(profile)) {
directRoutes.set(key, profile);
}
}
trimProfiles();
} catch {
// Missing or malformed local learning data must not affect transfers.
}
})();
return loadPromise;
}
export async function flushTransferProfiles(): Promise<void> {
if (persistTimer) {
clearTimeout(persistTimer);
persistTimer = undefined;
}
persistPromise = persistPromise.then(persistProfiles);
await persistPromise;
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, Math.floor(value)));
}
export function selectTransferTuning(
fileSize: number,
profile?: TransferPerformanceProfile,
requestedLanes?: number,
): TransferTuning {
if (fileSize < 32 * MB) {
return { parallelSegmentCount: 1, pipelineConcurrency: 8 };
}
let lanes = fileSize >= 1024 * MB ? 4 : 2;
let pipelineConcurrency = fileSize >= 256 * MB ? 32 : 16;
if (profile) {
lanes = profile.preferredLanes;
pipelineConcurrency = profile.pipelineConcurrency;
if (profile.failureRate >= 0.2) {
lanes = Math.min(lanes, 2);
pipelineConcurrency = Math.min(pipelineConcurrency, 16);
}
}
if (requestedLanes !== undefined) lanes = requestedLanes;
const segmentCapacity = Math.max(1, Math.ceil(fileSize / (256 * MB)));
return {
parallelSegmentCount: clamp(lanes, 1, Math.min(8, segmentCapacity)),
pipelineConcurrency: clamp(pipelineConcurrency, 8, 64),
};
}
export function updateTransferProfile(
previous: TransferPerformanceProfile | undefined,
observation: {
bytes: number;
durationMs: number;
lanes: number;
pipelineConcurrency: number;
failed: boolean;
now?: number;
},
): TransferPerformanceProfile {
const now = observation.now ?? Date.now();
const throughputBps =
observation.durationMs > 0
? (observation.bytes * 1000) / observation.durationMs
: 0;
const weight = previous ? 0.25 : 1;
const smoothedThroughput = previous
? previous.throughputBps * (1 - weight) + throughputBps * weight
: throughputBps;
const failure = observation.failed ? 1 : 0;
const failureRate = previous
? previous.failureRate * (1 - weight) + failure * weight
: failure;
let preferredLanes = observation.lanes;
let pipelineConcurrency = observation.pipelineConcurrency;
if (failureRate >= 0.2) {
preferredLanes = Math.max(1, Math.floor(preferredLanes / 2));
pipelineConcurrency = Math.max(8, Math.floor(pipelineConcurrency / 2));
} else if (
!observation.failed &&
previous &&
previous.samples >= 2 &&
throughputBps > previous.throughputBps * 1.25
) {
preferredLanes = Math.min(8, preferredLanes + 1);
pipelineConcurrency = Math.min(64, pipelineConcurrency + 8);
}
return {
throughputBps: smoothedThroughput,
failureRate,
samples: (previous?.samples ?? 0) + 1,
preferredLanes,
pipelineConcurrency,
updatedAt: now,
};
}
export function getTransferProfile(
key: string,
now = Date.now(),
): TransferPerformanceProfile | undefined {
const id = profileId(key);
const profile = profiles.get(id);
if (!profile) return undefined;
if (now - profile.updatedAt <= PROFILE_TTL_MS) return profile;
profiles.delete(id);
queuePersist();
return undefined;
}
export function recordTransferProfile(
key: string,
observation: Parameters<typeof updateTransferProfile>[1],
): TransferPerformanceProfile {
const profile = updateTransferProfile(getTransferProfile(key), observation);
profiles.set(profileId(key), profile);
trimProfiles(observation.now);
queuePersist();
return profile;
}
export function getDirectRouteProfile(
key: string,
now = Date.now(),
): DirectRouteProfile | undefined {
const id = profileId(key);
const profile = directRoutes.get(id);
if (!profile) return undefined;
if (now - profile.updatedAt <= PROFILE_TTL_MS) return profile;
directRoutes.delete(id);
queuePersist();
return undefined;
}
export function recordDirectRouteBenchmark(
key: string,
directMs: number,
relayMs: number,
now = Date.now(),
): DirectRouteProfile {
const previous = getDirectRouteProfile(key, now);
const weight = previous ? 0.25 : 1;
const profile: DirectRouteProfile = {
directMs: previous
? previous.directMs * (1 - weight) + directMs * weight
: directMs,
relayMs: previous
? previous.relayMs * (1 - weight) + relayMs * weight
: relayMs,
failureRate: previous?.failureRate ?? 0,
benchmarkSamples: (previous?.benchmarkSamples ?? 0) + 1,
outcomeSamples: previous?.outcomeSamples ?? 0,
benchmarkedAt: now,
cooldownUntil: previous?.cooldownUntil,
updatedAt: now,
};
directRoutes.set(profileId(key), profile);
trimProfiles(now);
queuePersist();
return profile;
}
export function recordDirectRouteOutcome(
key: string,
failed: boolean,
now = Date.now(),
cooldownMs = 10 * 60 * 1000,
): DirectRouteProfile | undefined {
const previous = getDirectRouteProfile(key, now);
if (!previous) return undefined;
const failure = failed ? 1 : 0;
const weight = previous.outcomeSamples > 0 ? 0.25 : 1;
const profile = {
...previous,
failureRate: previous.failureRate * (1 - weight) + failure * weight,
outcomeSamples: previous.outcomeSamples + 1,
cooldownUntil: failed ? now + cooldownMs : undefined,
updatedAt: now,
};
directRoutes.set(profileId(key), profile);
queuePersist();
return profile;
}
export function getRecentDirectRouteDecision(
key: string,
maxAgeMs: number,
now = Date.now(),
): { useDirect: boolean; directMs: number; relayMs: number } | undefined {
const profile = getDirectRouteProfile(key, now);
if (!profile) return undefined;
if ((profile.cooldownUntil ?? 0) > now) {
return {
useDirect: false,
directMs: profile.directMs,
relayMs: profile.relayMs,
};
}
if (now - profile.benchmarkedAt > maxAgeMs) return undefined;
return {
useDirect:
profile.failureRate < 0.2 && profile.directMs <= profile.relayMs * 0.8,
directMs: profile.directMs,
relayMs: profile.relayMs,
};
}
export function clearTransferProfiles(): void {
if (persistTimer) clearTimeout(persistTimer);
persistTimer = undefined;
loadedStore = null;
loadPromise = undefined;
profiles.clear();
directRoutes.clear();
}
@@ -0,0 +1,234 @@
import crypto from "node:crypto";
import path from "node:path";
import type { SFTPWrapper, Stats } from "ssh2";
export interface TrashItem {
id: string;
name: string;
originalPath: string;
isDirectory: boolean;
deletedAt: string;
size: number;
}
type StoredTrashItem = TrashItem & { trashPath: string };
const TRASH_DIR = ".termix-trash";
const ID_PATTERN = /^[0-9a-f-]{36}$/i;
function call<T>(
run: (done: (error: Error | undefined, value: T) => void) => void,
) {
return new Promise<T>((resolve, reject) => {
run((error, value) => (error ? reject(error) : resolve(value)));
});
}
function stat(sftp: SFTPWrapper, target: string) {
return call<Stats>((done) => sftp.stat(target, done));
}
function lstat(sftp: SFTPWrapper, target: string) {
return call<Stats>((done) => sftp.lstat(target, done));
}
function readdir(sftp: SFTPWrapper, target: string) {
return call<import("ssh2").FileEntry[]>((done) => sftp.readdir(target, done));
}
function readFile(sftp: SFTPWrapper, target: string) {
return call<Buffer>((done) => sftp.readFile(target, done));
}
function writeFile(sftp: SFTPWrapper, target: string, data: string) {
return call<void>((done) => sftp.writeFile(target, data, done));
}
function rename(sftp: SFTPWrapper, from: string, to: string) {
return call<void>((done) => sftp.rename(from, to, done));
}
function unlink(sftp: SFTPWrapper, target: string) {
return call<void>((done) => sftp.unlink(target, done));
}
function mkdir(sftp: SFTPWrapper, target: string) {
return call<void>((done) => sftp.mkdir(target, done));
}
function rmdir(sftp: SFTPWrapper, target: string) {
return call<void>((done) => sftp.rmdir(target, done));
}
async function exists(sftp: SFTPWrapper, target: string) {
try {
await stat(sftp, target);
return true;
} catch {
return false;
}
}
async function ensureDirectory(sftp: SFTPWrapper, target: string) {
const normalized = target.replace(/\\/g, "/");
const root = normalized.startsWith("/") ? "/" : "";
const parts = normalized.split("/").filter(Boolean);
let current = root;
for (const part of parts) {
current =
current === "/" ? `/${part}` : current ? `${current}/${part}` : part;
if (await exists(sftp, current)) continue;
await mkdir(sftp, current);
}
}
async function removeTree(sftp: SFTPWrapper, target: string): Promise<void> {
const targetStat = await lstat(sftp, target);
if (!targetStat.isDirectory()) {
await unlink(sftp, target);
return;
}
for (const entry of await readdir(sftp, target)) {
if (entry.filename === "." || entry.filename === "..") continue;
await removeTree(sftp, path.posix.join(target, entry.filename));
}
await rmdir(sftp, target);
}
async function trashPaths(sftp: SFTPWrapper) {
const home = await call<string>((done) => sftp.realpath(".", done));
const root = path.posix.join(home.replace(/\\/g, "/"), TRASH_DIR);
const files = path.posix.join(root, "files");
const info = path.posix.join(root, "info");
await ensureDirectory(sftp, files);
await ensureDirectory(sftp, info);
return { root, files, info };
}
export function isSafeTrashSource(itemPath: string, trashRoot: string) {
const normalized = path.posix.normalize(itemPath.replace(/\\/g, "/"));
const root = path.posix.normalize(trashRoot);
return (
normalized !== "." &&
normalized !== "/" &&
!/^[A-Za-z]:\/?$/.test(normalized) &&
normalized !== root &&
!normalized.startsWith(`${root}/`)
);
}
function publicItem(item: StoredTrashItem): TrashItem {
const { trashPath: _trashPath, ...result } = item;
return result;
}
async function readStoredItem(
sftp: SFTPWrapper,
dirs: { root: string; files: string; info: string },
id: string,
): Promise<StoredTrashItem> {
if (!ID_PATTERN.test(id)) throw new Error("Invalid trash item id");
const parsed = JSON.parse(
(await readFile(sftp, path.posix.join(dirs.info, `${id}.json`))).toString(
"utf8",
),
) as StoredTrashItem;
if (
parsed.id !== id ||
parsed.trashPath !== path.posix.join(dirs.files, id) ||
!isSafeTrashSource(parsed.originalPath, dirs.root)
) {
throw new Error("Invalid trash metadata");
}
return parsed;
}
export async function moveToTrash(
sftp: SFTPWrapper,
itemPath: string,
): Promise<TrashItem> {
const dirs = await trashPaths(sftp);
if (!isSafeTrashSource(itemPath, dirs.root)) {
throw new Error("This path cannot be moved to trash");
}
const itemStat = await lstat(sftp, itemPath);
const id = crypto.randomUUID();
const trashPath = path.posix.join(dirs.files, id);
const item: StoredTrashItem = {
id,
name: path.posix.basename(itemPath.replace(/\\/g, "/")),
originalPath: itemPath,
trashPath,
isDirectory: itemStat.isDirectory(),
deletedAt: new Date().toISOString(),
size: itemStat.size,
};
await rename(sftp, itemPath, trashPath);
try {
await writeFile(
sftp,
path.posix.join(dirs.info, `${id}.json`),
JSON.stringify(item),
);
} catch (error) {
await rename(sftp, trashPath, itemPath).catch(() => {});
throw error;
}
return publicItem(item);
}
export async function listTrash(
sftp: SFTPWrapper,
retentionDays: number,
): Promise<TrashItem[]> {
const dirs = await trashPaths(sftp);
const cutoff = Date.now() - retentionDays * 86_400_000;
const items: TrashItem[] = [];
for (const entry of await readdir(sftp, dirs.info)) {
if (!entry.filename.endsWith(".json")) continue;
const id = entry.filename.slice(0, -5);
try {
const item = await readStoredItem(sftp, dirs, id);
if (new Date(item.deletedAt).getTime() < cutoff) {
if (await exists(sftp, item.trashPath))
await removeTree(sftp, item.trashPath);
await unlink(sftp, path.posix.join(dirs.info, entry.filename));
continue;
}
if (await exists(sftp, item.trashPath)) items.push(publicItem(item));
else await unlink(sftp, path.posix.join(dirs.info, entry.filename));
} catch {
// Ignore corrupt metadata without exposing arbitrary paths to deletion.
}
}
return items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
}
export async function restoreTrashItem(sftp: SFTPWrapper, id: string) {
const dirs = await trashPaths(sftp);
const item = await readStoredItem(sftp, dirs, id);
if (await exists(sftp, item.originalPath)) {
throw new Error("A file already exists at the original path");
}
await rename(sftp, item.trashPath, item.originalPath);
await unlink(sftp, path.posix.join(dirs.info, `${id}.json`));
return publicItem(item);
}
export async function permanentlyDeleteTrashItem(
sftp: SFTPWrapper,
id: string,
) {
const dirs = await trashPaths(sftp);
const item = await readStoredItem(sftp, dirs, id);
if (await exists(sftp, item.trashPath))
await removeTree(sftp, item.trashPath);
await unlink(sftp, path.posix.join(dirs.info, `${id}.json`));
}
export async function emptyTrash(sftp: SFTPWrapper) {
const items = await listTrash(sftp, 365_000);
for (const item of items) await permanentlyDeleteTrashItem(sftp, item.id);
return items.length;
}
+164
View File
@@ -0,0 +1,164 @@
export function isExecutableFile(
permissions: string,
fileName: string,
): boolean {
const hasExecutePermission =
permissions[3] === "x" || permissions[6] === "x" || permissions[9] === "x";
const scriptExtensions = [
".sh",
".py",
".pl",
".rb",
".js",
".php",
".bash",
".zsh",
".fish",
];
const hasScriptExtension = scriptExtensions.some((ext) =>
fileName.toLowerCase().endsWith(ext),
);
const executableExtensions = [".bin", ".exe", ".out"];
const hasExecutableExtension = executableExtensions.some((ext) =>
fileName.toLowerCase().endsWith(ext),
);
const hasNoExtension = !fileName.includes(".") && hasExecutePermission;
return (
hasExecutePermission &&
(hasScriptExtension || hasExecutableExtension || hasNoExtension)
);
}
export function modeToPermissions(mode: number): string {
const S_IFDIR = 0o040000;
const S_IFLNK = 0o120000;
const S_IFMT = 0o170000;
const type = mode & S_IFMT;
const prefix = type === S_IFDIR ? "d" : type === S_IFLNK ? "l" : "-";
const perms = [
mode & 0o400 ? "r" : "-",
mode & 0o200 ? "w" : "-",
mode & 0o100 ? "x" : "-",
mode & 0o040 ? "r" : "-",
mode & 0o020 ? "w" : "-",
mode & 0o010 ? "x" : "-",
mode & 0o004 ? "r" : "-",
mode & 0o002 ? "w" : "-",
mode & 0o001 ? "x" : "-",
].join("");
return prefix + perms;
}
export function parseLsDateToTimestamp(dateStr: string): number {
const parts = dateStr.trim().split(/\s+/);
if (parts.length < 3) return 0;
const [month, day, timeOrYear] = parts;
const monthIndex = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
].indexOf(month);
if (monthIndex === -1) return 0;
const dayNum = parseInt(day, 10);
const now = new Date();
if (timeOrYear.includes(":")) {
const [hours, minutes] = timeOrYear.split(":").map(Number);
let year = now.getFullYear();
const candidate = new Date(year, monthIndex, dayNum, hours, minutes);
if (candidate > now) year -= 1;
return new Date(year, monthIndex, dayNum, hours, minutes).getTime() / 1000;
}
const year = parseInt(timeOrYear, 10);
if (isNaN(year)) return 0;
return new Date(year, monthIndex, dayNum).getTime() / 1000;
}
export function formatMtime(mtime: number): string {
const date = new Date(mtime * 1000);
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const month = months[date.getMonth()];
const day = date.getDate().toString().padStart(2, " ");
const now = new Date();
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000);
if (date > sixMonthsAgo) {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${month} ${day} ${hours}:${minutes}`;
}
return `${month} ${day} ${date.getFullYear()}`;
}
export function getMimeType(fileName: string): string {
const ext = fileName.split(".").pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
txt: "text/plain",
json: "application/json",
js: "text/javascript",
html: "text/html",
css: "text/css",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
pdf: "application/pdf",
zip: "application/zip",
tar: "application/x-tar",
gz: "application/gzip",
};
return mimeTypes[ext || ""] || "application/octet-stream";
}
export function detectBinary(buffer: Buffer): boolean {
if (buffer.length === 0) return false;
const sampleSize = Math.min(buffer.length, 8192);
let nullBytes = 0;
for (let i = 0; i < sampleSize; i++) {
const byte = buffer[i];
if (byte === 0) {
nullBytes++;
}
if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) {
if (++nullBytes > 1) return true;
}
}
return nullBytes / sampleSize > 0.01;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,53 @@
import React from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { FileManager } from "./FileManager.tsx";
import { FullScreenAppWrapper, ConnectionScreen } from "@termix/plugin-sdk/ui";
interface FileManagerAppProps {
hostId?: string;
initialPath?: string;
}
const FileManagerApp: React.FC<FileManagerAppProps> = ({
hostId,
initialPath,
}) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, phase) => {
if (phase === "loading") {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="connecting"
message={t("hosts.loadingHost")}
/>
</div>
);
}
if (!hostConfig) {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="disconnected"
message={t("hosts.hostNotFound")}
/>
</div>
);
}
return (
<FileManager
initialHost={hostConfig}
initialPath={initialPath}
onClose={() => {}}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default FileManagerApp;
@@ -0,0 +1,622 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@termix/plugin-sdk/ui";
import {
Download,
Edit3,
Copy,
Scissors,
Trash2,
Info,
Upload,
FolderPlus,
FilePlus,
RefreshCw,
Clipboard,
Eye,
Terminal,
Play,
Star,
Bookmark,
FileArchive,
ArrowRightLeft,
Link,
} from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Kbd, KbdKey, KbdSeparator } from "@termix/plugin-sdk/ui";
const VIEWPORT_PADDING = 16;
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
executable?: boolean;
}
interface ContextMenuProps {
x: number;
y: number;
files: FileItem[];
isVisible: boolean;
onClose: () => void;
onDownload?: (files: FileItem[]) => void;
onRename?: (file: FileItem) => void;
onCopy?: (files: FileItem[]) => void;
onCut?: (files: FileItem[]) => void;
onDelete?: (files: FileItem[]) => void;
onProperties?: (file: FileItem) => void;
onUpload?: () => void;
onNewFolder?: () => void;
onNewFile?: () => void;
onRefresh?: () => void;
onPaste?: () => void;
onPreview?: (file: FileItem) => void;
hasClipboard?: boolean;
onDragToDesktop?: () => void;
onOpenTerminal?: (path: string) => void;
onRunExecutable?: (file: FileItem) => void;
onPinFile?: (file: FileItem) => void;
onUnpinFile?: (file: FileItem) => void;
onAddShortcut?: (path: string) => void;
isPinned?: (file: FileItem) => boolean;
currentPath?: string;
onExtractArchive?: (file: FileItem) => void;
onCompress?: (files: FileItem[]) => void;
onCopyPath?: (files: FileItem[]) => void;
onCopyFolderLink?: (path: string) => void;
onTransferToHost?: (files: FileItem[], move: boolean) => void;
}
interface MenuItem {
icon: React.ReactNode;
label: string;
action: () => void;
shortcut?: string;
separator?: boolean;
disabled?: boolean;
danger?: boolean;
}
export function FileManagerContextMenu({
x,
y,
files,
isVisible,
onClose,
onDownload,
onRename,
onCopy,
onCut,
onDelete,
onProperties,
onUpload,
onNewFolder,
onNewFile,
onRefresh,
onPaste,
onPreview,
hasClipboard = false,
onDragToDesktop,
onOpenTerminal,
onRunExecutable,
onPinFile,
onUnpinFile,
onAddShortcut,
isPinned,
currentPath,
onExtractArchive,
onCompress,
onCopyPath,
onCopyFolderLink,
onTransferToHost,
}: ContextMenuProps) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
const [menuPosition, setMenuPosition] = useState({ x, y });
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
if (!isVisible) {
setIsMounted(false);
return;
}
setIsMounted(true);
let cleanupFn: (() => void) | null = null;
const timeoutId = setTimeout(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
const menuElement = document.querySelector("[data-context-menu]");
if (!menuElement?.contains(target)) {
onClose();
}
};
const handleRightClick = (event: MouseEvent) => {
event.preventDefault();
onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
};
const handleBlur = () => {
onClose();
};
const handleScroll = () => {
onClose();
};
document.addEventListener("mousedown", handleClickOutside, true);
document.addEventListener("contextmenu", handleRightClick);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("blur", handleBlur);
window.addEventListener("scroll", handleScroll, true);
cleanupFn = () => {
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("contextmenu", handleRightClick);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("blur", handleBlur);
window.removeEventListener("scroll", handleScroll, true);
};
}, 50);
return () => {
clearTimeout(timeoutId);
if (cleanupFn) {
cleanupFn();
}
};
}, [isVisible, x, y, onClose]);
useLayoutEffect(() => {
if (!isVisible || !menuRef.current) return;
const menuWidth = menuRef.current.offsetWidth;
const menuHeight = menuRef.current.offsetHeight;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let adjustedX = x;
let adjustedY = y;
if (x + menuWidth > viewportWidth)
adjustedX = viewportWidth - menuWidth - 10;
if (y + menuHeight > viewportHeight)
adjustedY = Math.max(10, viewportHeight - menuHeight - 10);
adjustedX = Math.max(8, adjustedX);
adjustedY = Math.max(8, adjustedY);
setMenuPosition({ x: adjustedX, y: adjustedY });
}, [isVisible, x, y, files.length]);
const isFileContext = files.length > 0;
const isSingleFile = files.length === 1;
const isMultipleFiles = files.length > 1;
const hasFiles = files.some((f) => f.type === "file");
const hasExecutableFiles = files.some(
(f) => f.type === "file" && f.executable,
);
const menuItems: MenuItem[] = [];
if (isFileContext) {
if (onOpenTerminal) {
const targetPath = isSingleFile
? files[0].type === "directory"
? files[0].path
: files[0].path.substring(0, files[0].path.lastIndexOf("/"))
: files[0].path.substring(0, files[0].path.lastIndexOf("/"));
menuItems.push({
icon: <Terminal className="size-3.5" />,
label:
files[0].type === "directory"
? t("fileManager.openTerminalInFolder")
: t("fileManager.openTerminalInFileLocation"),
action: () => onOpenTerminal(targetPath),
shortcut: "Ctrl+Shift+T",
});
}
if (isSingleFile && hasExecutableFiles && onRunExecutable) {
menuItems.push({
icon: <Play className="size-3.5" />,
label: t("fileManager.run"),
action: () => onRunExecutable(files[0]),
shortcut: "Enter",
});
}
if (
onOpenTerminal ||
(isSingleFile && hasExecutableFiles && onRunExecutable)
) {
menuItems.push({ separator: true } as MenuItem);
}
if (hasFiles && onPreview) {
menuItems.push({
icon: <Eye className="size-3.5" />,
label: t("fileManager.preview"),
action: () => onPreview(files[0]),
disabled: !isSingleFile || files[0].type !== "file",
});
}
if (hasFiles && onDownload) {
menuItems.push({
icon: <Download className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.downloadFiles", { count: files.length })
: t("fileManager.downloadFile"),
action: () => onDownload(files),
shortcut: "Ctrl+D",
});
}
if (isFileContext && onTransferToHost) {
const isOnlyDirectories =
files.length > 0 && files.every((f) => f.type === "directory");
menuItems.push({
icon: <ArrowRightLeft className="size-3.5" />,
label: isMultipleFiles
? t("transfer.copyItemsToHost", { count: files.length })
: isOnlyDirectories && isSingleFile
? t("transfer.copyFolderToHost")
: t("transfer.copyToHost"),
action: () => onTransferToHost(files, false),
});
menuItems.push({
icon: <ArrowRightLeft className="size-3.5" />,
label: isMultipleFiles
? t("transfer.moveItemsToHost", { count: files.length })
: isOnlyDirectories && isSingleFile
? t("transfer.moveFolderToHost")
: t("transfer.moveToHost"),
action: () => onTransferToHost(files, true),
});
}
if (isSingleFile && files[0].type === "file" && onExtractArchive) {
const fileName = files[0].name.toLowerCase();
const isArchive =
fileName.endsWith(".zip") ||
fileName.endsWith(".tar") ||
fileName.endsWith(".tar.gz") ||
fileName.endsWith(".tgz") ||
fileName.endsWith(".tar.bz2") ||
fileName.endsWith(".tbz2") ||
fileName.endsWith(".tar.xz") ||
fileName.endsWith(".gz") ||
fileName.endsWith(".bz2") ||
fileName.endsWith(".xz") ||
fileName.endsWith(".7z") ||
fileName.endsWith(".rar");
if (isArchive) {
menuItems.push({
icon: <FileArchive className="size-3.5" />,
label: t("fileManager.extractArchive"),
action: () => onExtractArchive(files[0]),
shortcut: "Ctrl+E",
});
}
}
if (isFileContext && onCompress) {
menuItems.push({
icon: <FileArchive className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.compressFiles")
: t("fileManager.compressFile"),
action: () => onCompress(files),
shortcut: "Ctrl+Shift+C",
});
}
if (isSingleFile && files[0].type === "file") {
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
if (isCurrentlyPinned && onUnpinFile) {
menuItems.push({
icon: (
<Star className="size-3.5 fill-accent-brand text-accent-brand" />
),
label: t("fileManager.unpinFile"),
action: () => onUnpinFile(files[0]),
});
} else if (!isCurrentlyPinned && onPinFile) {
menuItems.push({
icon: <Star className="size-3.5" />,
label: t("fileManager.pinFile"),
action: () => onPinFile(files[0]),
});
}
}
if (isSingleFile && files[0].type === "directory" && onAddShortcut) {
menuItems.push({
icon: <Bookmark className="size-3.5" />,
label: t("fileManager.addToShortcuts"),
action: () => onAddShortcut(files[0].path),
});
}
if (isSingleFile && files[0].type === "directory" && onCopyFolderLink) {
menuItems.push({
icon: <Link className="size-3.5" />,
label: t("fileManager.copyFolderLink"),
action: () => onCopyFolderLink(files[0].path),
});
}
if (
(hasFiles && (onPreview || onDragToDesktop)) ||
(isSingleFile &&
files[0].type === "file" &&
(onPinFile || onUnpinFile)) ||
(isSingleFile &&
files[0].type === "directory" &&
(onAddShortcut || onCopyFolderLink))
) {
menuItems.push({ separator: true } as MenuItem);
}
if (isSingleFile && onRename) {
menuItems.push({
icon: <Edit3 className="size-3.5" />,
label: t("fileManager.rename"),
action: () => onRename(files[0]),
shortcut: "F2",
});
}
if (onCopy) {
menuItems.push({
icon: <Copy className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.copyFiles", { count: files.length })
: t("fileManager.copy"),
action: () => onCopy(files),
shortcut: "Ctrl+C",
});
}
if (onCut) {
menuItems.push({
icon: <Scissors className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.cutFiles", { count: files.length })
: t("fileManager.cut"),
action: () => onCut(files),
shortcut: "Ctrl+X",
});
}
if (onCopyPath) {
menuItems.push({
icon: <Clipboard className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.copyPaths")
: t("fileManager.copyPath"),
action: () => onCopyPath(files),
shortcut: "Ctrl+Shift+P",
});
}
if ((isSingleFile && onRename) || onCopy || onCut || onCopyPath) {
menuItems.push({ separator: true } as MenuItem);
}
if (isSingleFile && onProperties) {
menuItems.push({
icon: <Info className="size-3.5" />,
label: t("fileManager.properties"),
action: () => onProperties(files[0]),
});
}
if ((isSingleFile && onProperties) || onDelete) {
menuItems.push({ separator: true } as MenuItem);
}
if (onDelete) {
menuItems.push({
icon: <Trash2 className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.deleteFiles", { count: files.length })
: t("fileManager.delete"),
action: () => onDelete(files),
shortcut: "Delete",
danger: true,
});
}
} else {
if (onOpenTerminal && currentPath) {
menuItems.push({
icon: <Terminal className="size-3.5" />,
label: t("fileManager.openTerminalHere"),
action: () => onOpenTerminal(currentPath),
shortcut: "Ctrl+Shift+T",
});
}
if (onUpload) {
menuItems.push({
icon: <Upload className="size-3.5" />,
label: t("fileManager.uploadFile"),
action: onUpload,
shortcut: "Ctrl+U",
});
}
if ((onOpenTerminal && currentPath) || onUpload) {
menuItems.push({ separator: true } as MenuItem);
}
if (onNewFolder) {
menuItems.push({
icon: <FolderPlus className="size-3.5" />,
label: t("fileManager.newFolder"),
action: onNewFolder,
shortcut: "Ctrl+Shift+N",
});
}
if (onNewFile) {
menuItems.push({
icon: <FilePlus className="size-3.5" />,
label: t("fileManager.newFile"),
action: onNewFile,
shortcut: "Ctrl+N",
});
}
if (onNewFolder || onNewFile) {
menuItems.push({ separator: true } as MenuItem);
}
if (onRefresh) {
menuItems.push({
icon: <RefreshCw className="size-3.5" />,
label: t("fileManager.refresh"),
action: onRefresh,
shortcut: "Ctrl+Y",
});
}
if (hasClipboard && onPaste) {
menuItems.push({
icon: <Clipboard className="size-3.5" />,
label: t("fileManager.paste"),
action: onPaste,
shortcut: "Ctrl+V",
});
}
if (onCopyFolderLink && currentPath) {
menuItems.push({ separator: true } as MenuItem);
menuItems.push({
icon: <Link className="size-3.5" />,
label: t("fileManager.copyCurrentFolderLink"),
action: () => onCopyFolderLink(currentPath),
});
}
}
const filteredMenuItems = menuItems.filter((item, index) => {
if (!item.separator) return true;
const prevItem = index > 0 ? menuItems[index - 1] : null;
const nextItem = index < menuItems.length - 1 ? menuItems[index + 1] : null;
if (prevItem?.separator || nextItem?.separator) {
return false;
}
return true;
});
const finalMenuItems = filteredMenuItems.filter((item, index) => {
if (!item.separator) return true;
return index > 0 && index < filteredMenuItems.length - 1;
});
const renderShortcut = (shortcut: string) => {
const keys = shortcut.split("+");
if (keys.length === 1) {
return <Kbd>{keys[0]}</Kbd>;
}
return (
<Kbd>
{keys.map((key, index) => (
<>
<KbdKey key={`key-${index}`}>{key}</KbdKey>
{index < keys.length - 1 && <KbdSeparator key={`sep-${index}`} />}
</>
))}
</Kbd>
);
};
if (!isVisible && !isMounted) return null;
return (
<>
<div
className={cn(
"fixed inset-0 z-[99990] transition-opacity duration-150",
!isMounted && "opacity-0",
)}
/>
<div
ref={menuRef}
data-context-menu
className={cn(
"fixed bg-card border border-border rounded-none shadow-md min-w-[220px] max-w-[300px] z-[99995] overflow-x-hidden overflow-y-auto py-1",
)}
style={{
left: menuPosition.x,
top: menuPosition.y,
maxHeight: `calc(100vh - ${VIEWPORT_PADDING * 2}px)`,
}}
>
{finalMenuItems.map((item, index) => {
if (item.separator) {
return (
<div
key={`separator-${index}`}
className="my-1 border-t border-border"
/>
);
}
return (
<button
key={index}
className={cn(
"w-full px-3 min-h-8 py-1.5 text-left text-xs font-semibold flex items-center justify-between gap-3 rounded-none transition-colors cursor-pointer",
"hover:bg-accent-brand/10 hover:text-accent-brand",
item.disabled &&
"opacity-40 cursor-not-allowed hover:bg-transparent hover:text-current",
item.danger &&
"text-destructive hover:bg-destructive/10 hover:text-destructive",
)}
onClick={() => {
if (!item.disabled) {
item.action();
onClose();
}
}}
disabled={item.disabled}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-shrink-0 text-muted-foreground">
{item.icon}
</div>
<span className="flex-1 leading-tight">{item.label}</span>
</div>
{item.shortcut && (
<div className="ml-auto flex-shrink-0 opacity-50">
{renderShortcut(item.shortcut)}
</div>
)}
</button>
);
})}
</div>
</>
);
}
@@ -0,0 +1,136 @@
import type { FileItem, SSHHost } from "./host-types";
import {
TOTPDialog,
SSHAuthDialog,
BrowserSignInDialog,
} from "@termix/plugin-sdk/ui";
import { PermissionsDialog } from "./components/PermissionsDialog.tsx";
import { CompressDialog } from "./components/CompressDialog.tsx";
import { SudoPasswordDialog } from "./SudoPasswordDialog.tsx";
import type { PendingSudoOperation } from "./file-manager-types.ts";
type FileManagerDialogsProps = {
compressDialogFiles: FileItem[];
setCompressDialogFiles: (files: FileItem[]) => void;
handleCompress: (archiveName: string, format: string) => void | Promise<void>;
totpRequired: boolean;
totpPrompt: string;
handleTotpSubmit: (code: string) => void | Promise<void>;
handleTotpCancel: () => void;
browserSignInRequired: boolean;
browserSignInUrl: string;
browserSignInCode: string;
browserSignInLabel: string;
handleBrowserSignInContinue: () => void | Promise<void>;
handleBrowserSignInCancel: () => void;
handleBrowserSignInOpenUrl: () => void;
currentHost: SSHHost | null;
showAuthDialog: boolean;
authDialogReason: "no_keyboard" | "auth_failed" | "timeout";
handleAuthDialogSubmit: (credentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
}) => void | Promise<void>;
handleAuthDialogCancel: () => void;
permissionsDialogFile: FileItem | null;
setPermissionsDialogFile: (file: FileItem | null) => void;
handleSavePermissions: (file: FileItem, permissions: string) => Promise<void>;
sudoDialogOpen: boolean;
setSudoDialogOpen: (open: boolean) => void;
setPendingSudoOperation: (operation: PendingSudoOperation | null) => void;
handleSudoPasswordSubmit: (password: string) => void | Promise<void>;
};
export function FileManagerDialogs({
compressDialogFiles,
setCompressDialogFiles,
handleCompress,
totpRequired,
totpPrompt,
handleTotpSubmit,
handleTotpCancel,
browserSignInRequired,
browserSignInUrl,
browserSignInCode,
browserSignInLabel,
handleBrowserSignInContinue,
handleBrowserSignInCancel,
handleBrowserSignInOpenUrl,
currentHost,
showAuthDialog,
authDialogReason,
handleAuthDialogSubmit,
handleAuthDialogCancel,
permissionsDialogFile,
setPermissionsDialogFile,
handleSavePermissions,
sudoDialogOpen,
setSudoDialogOpen,
setPendingSudoOperation,
handleSudoPasswordSubmit,
}: FileManagerDialogsProps) {
return (
<>
<CompressDialog
open={compressDialogFiles.length > 0}
onOpenChange={(open) => !open && setCompressDialogFiles([])}
fileNames={compressDialogFiles.map((f) => f.name)}
onCompress={handleCompress}
/>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
backgroundColor="var(--bg-canvas)"
/>
<BrowserSignInDialog
isOpen={browserSignInRequired}
label={browserSignInLabel}
url={browserSignInUrl}
code={browserSignInCode}
onContinue={handleBrowserSignInContinue}
onCancel={handleBrowserSignInCancel}
onOpenUrl={handleBrowserSignInOpenUrl}
backgroundColor="var(--bg-canvas)"
/>
{currentHost && (
<SSHAuthDialog
isOpen={showAuthDialog}
reason={authDialogReason}
onSubmit={handleAuthDialogSubmit}
onCancel={handleAuthDialogCancel}
hostInfo={{
ip: currentHost.ip,
port: currentHost.port,
username: currentHost.username,
name: currentHost.name,
}}
backgroundColor="var(--bg-canvas)"
/>
)}
<PermissionsDialog
file={permissionsDialogFile}
open={permissionsDialogFile !== null}
onOpenChange={(open) => {
if (!open) setPermissionsDialogFile(null);
}}
onSave={handleSavePermissions}
/>
<SudoPasswordDialog
open={sudoDialogOpen}
onOpenChange={(open) => {
setSudoDialogOpen(open);
if (!open) setPendingSudoOperation(null);
}}
onSubmit={handleSudoPasswordSubmit}
/>
</>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,931 @@
import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from "react";
import { cn } from "@termix/plugin-sdk/ui";
import {
Star,
Clock,
Bookmark,
File,
Folder,
Trash2,
ChevronDown,
Check,
} from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { SSHHost } from "./host-types";
import {
getRecentFiles,
getPinnedFiles,
getFolderShortcuts,
removeRecentFile,
removePinnedFile,
removeFolderShortcut,
} from "./api/file-manager-data-api";
import { listSSHFiles } from "./api/ssh-file-operations-api";
import { mountForPath, type DiskFilesystem } from "./disk-info";
import { toast } from "sonner";
import FolderTree from "./components/FolderTree";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@termix/plugin-sdk/ui";
// ─── Interfaces ────────────────────────────────────────────────────────────────
interface RecentFileData {
id: number;
name: string;
path: string;
lastOpened?: string;
[key: string]: unknown;
}
interface PinnedFileData {
id: number;
name: string;
path: string;
[key: string]: unknown;
}
interface ShortcutData {
id: number;
name: string;
path: string;
[key: string]: unknown;
}
interface DirectoryItemData {
name: string;
path: string;
type: string;
[key: string]: unknown;
}
export interface SidebarItem {
id: string;
name: string;
path: string;
type: "recent" | "pinned" | "shortcut" | "folder";
lastAccessed?: string;
isExpanded?: boolean;
children?: SidebarItem[];
}
interface FileManagerSidebarProps {
density?: "comfortable" | "compact";
currentHost: SSHHost;
currentPath: string;
onPathChange: (path: string) => void;
onFileOpen?: (file: SidebarItem) => void;
/** Full file-manager context menu (same as main grid). */
onItemContextMenu?: (event: React.MouseEvent, item: SidebarItem) => void;
sshSessionId?: string;
refreshTrigger?: number;
onOpenTrash?: () => void;
diskInfo?: {
usedHuman: string;
totalHuman: string;
percent: number;
mount?: string | null;
filesystems?: DiskFilesystem[];
};
}
// ─── Storage meter ─────────────────────────────────────────────────────────────
function StorageMeter({
storage,
filesystems,
selectedMount,
onSelectMount,
}: {
storage: { percent: number; usedHuman: string; totalHuman: string };
filesystems: DiskFilesystem[];
selectedMount: string | null;
onSelectMount: (mount: string) => void;
}) {
const { t } = useTranslation();
const hasPicker = filesystems.length > 1;
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{hasPicker ? (
<DropdownMenu>
<DropdownMenuTrigger className="flex min-w-0 items-center gap-1 transition-colors hover:text-foreground">
<span className="truncate">
{selectedMount ?? t("fileManager.disk")}
</span>
<ChevronDown className="size-3 shrink-0" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-auto min-w-[220px] max-w-[min(360px,calc(100vw-2rem))]"
>
{filesystems.map((fs) => (
<DropdownMenuItem
key={fs.mount}
onSelect={() => onSelectMount(fs.mount)}
className="gap-3"
>
<Check
className={cn(
"size-3 shrink-0",
fs.mount === selectedMount ? "opacity-100" : "opacity-0",
)}
/>
<span className="min-w-0 flex-1 truncate font-medium">
{fs.mount}
</span>
<span className="ml-auto flex shrink-0 gap-2 whitespace-nowrap text-[10px] tabular-nums text-muted-foreground">
<span>
{fs.usedHuman && fs.totalHuman
? `${fs.usedHuman}/${fs.totalHuman}`
: "N/A"}
</span>
{fs.percent != null && <span>{fs.percent}%</span>}
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : (
<span className="truncate">
{selectedMount ?? t("fileManager.disk")}
</span>
)}
<span className="shrink-0 text-accent-brand">
{storage.percent}% {t("fileManager.used")}
</span>
</div>
<div className="h-1.5 bg-muted rounded-none overflow-hidden border border-border">
<div
className="h-full bg-accent-brand"
style={{ width: `${storage.percent}%` }}
/>
</div>
<span className="text-[10px] font-bold text-muted-foreground/60 tracking-tight">
{storage.usedHuman} {t("fileManager.of")} {storage.totalHuman}{" "}
{t("fileManager.used").toLowerCase()}
</span>
</div>
);
}
// ─── Component ─────────────────────────────────────────────────────────────────
export function FileManagerSidebar({
density = "comfortable",
currentHost,
currentPath,
onPathChange,
onFileOpen,
onItemContextMenu,
sshSessionId,
refreshTrigger,
onOpenTrash,
diskInfo,
}: FileManagerSidebarProps) {
const { t } = useTranslation();
// ── Quick access state (API-backed) ──────────────────────────────────────────
const [recentItems, setRecentItems] = useState<SidebarItem[]>([]);
const [pinnedItems, setPinnedItems] = useState<SidebarItem[]>([]);
const [shortcuts, setShortcuts] = useState<SidebarItem[]>([]);
// ── Directory tree state ──────────────────────────────────────────────────────
const [directoryTree, setDirectoryTree] = useState<SidebarItem[]>([]);
// ── Storage state ─────────────────────────────────────────────────────────────
const [selectedMount, setSelectedMount] = useState<string | null>(null);
/**
* Tracks which folder paths have already been lazy-loaded so we don't
* re-fetch on every re-selection / collapse-reopen.
*/
const loadedFoldersRef = useRef<Set<string>>(new Set(["/"]));
// ── Context menu state ────────────────────────────────────────────────────────
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
isVisible: boolean;
item: SidebarItem | null;
}>({
x: 0,
y: 0,
isVisible: false,
item: null,
});
// ─── Effects ──────────────────────────────────────────────────────────────────
// ─── API: Quick access ────────────────────────────────────────────────────────
const loadQuickAccessData = useCallback(async () => {
if (!currentHost?.id) return;
try {
const recentData = await getRecentFiles(currentHost.id);
const recentItems = (recentData as unknown as RecentFileData[])
.slice(0, 5)
.map((item: RecentFileData) => ({
id: `recent-${item.id}`,
name: item.name,
path: item.path,
type: "recent" as const,
lastAccessed: item.lastOpened,
}));
setRecentItems(recentItems);
const pinnedData = await getPinnedFiles(currentHost.id);
const pinnedItems = (pinnedData as unknown as PinnedFileData[]).map(
(item: PinnedFileData) => ({
id: `pinned-${item.id}`,
name: item.name,
path: item.path,
type: "pinned" as const,
}),
);
setPinnedItems(pinnedItems);
const shortcutData = await getFolderShortcuts(currentHost.id);
const shortcutItems = (shortcutData as unknown as ShortcutData[]).map(
(item: ShortcutData) => ({
id: `shortcut-${item.id}`,
name: item.name,
path: item.path,
type: "shortcut" as const,
}),
);
setShortcuts(shortcutItems);
} catch (error) {
console.error("Failed to load quick access data:", error);
setRecentItems([]);
setPinnedItems([]);
setShortcuts([]);
}
}, [currentHost?.id]);
// ─── API: Directory tree ──────────────────────────────────────────────────────
const loadDirectoryTree = useCallback(
async (attempt = 0) => {
if (!sshSessionId) return;
try {
const response = await listSSHFiles(sshSessionId, "/");
const rootFiles = (response.files || []) as DirectoryItemData[];
const rootFolders = rootFiles
.filter((item: DirectoryItemData) => item.type === "directory")
.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
const rootTreeItems = rootFolders.map((folder: DirectoryItemData) => ({
id: `folder-${folder.name}`,
name: folder.name,
path: folder.path,
type: "folder" as const,
isExpanded: false,
children: [],
}));
setDirectoryTree([
{
id: "root",
name: "/",
path: "/",
type: "folder" as const,
isExpanded: true,
children: rootTreeItems,
},
]);
} catch (error: unknown) {
const status =
(error as { status?: number })?.status ||
(error as { response?: { status?: number } })?.response?.status;
if (status === 409 && attempt < 3) {
// Another request was already listing "/" — retry after a short delay
setTimeout(() => loadDirectoryTree(attempt + 1), 600);
return;
}
console.error("Failed to load directory tree:", error);
setDirectoryTree([
{
id: "root",
name: "/",
path: "/",
type: "folder" as const,
isExpanded: false,
children: [],
},
]);
}
},
[sshSessionId],
);
/**
* Lazily fetches subdirectory contents and patches them into the tree state.
* Called the first time a folder is expanded via FolderTree's onSelect.
*/
const loadSubdirectory = useCallback(
async (folderId: string, folderPath: string): Promise<boolean> => {
if (!sshSessionId) return false;
try {
const subResponse = await listSSHFiles(sshSessionId, folderPath);
const subFiles = (subResponse.files || []) as DirectoryItemData[];
const subFolders = subFiles
.filter((item: DirectoryItemData) => item.type === "directory")
.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
const subTreeItems = subFolders.map((folder: DirectoryItemData) => ({
id: `folder-${folder.path.replace(/\//g, "-")}`,
name: folder.name,
path: folder.path,
type: "folder" as const,
isExpanded: false,
children: [],
}));
setDirectoryTree((prevTree) => {
const updateChildren = (items: SidebarItem[]): SidebarItem[] =>
items.map((item) => {
if (item.id === folderId) {
return { ...item, children: subTreeItems };
}
if (item.children) {
return { ...item, children: updateChildren(item.children) };
}
return item;
});
return updateChildren(prevTree);
});
loadedFoldersRef.current.add(folderPath);
return true;
} catch (error: unknown) {
const status =
(error as { status?: number })?.status ||
(error as { response?: { status?: number } })?.response?.status;
if (status === 409) {
// Another request was listing this path — retry after the lock clears
setTimeout(() => void loadSubdirectory(folderId, folderPath), 600);
return false;
}
loadedFoldersRef.current.delete(folderPath);
console.error("Failed to load subdirectory:", error);
return false;
}
},
[sshSessionId],
);
useEffect(() => {
loadQuickAccessData();
}, [loadQuickAccessData, refreshTrigger]);
useEffect(() => {
if (sshSessionId) {
loadedFoldersRef.current = new Set(["/"]);
loadDirectoryTree();
}
}, [loadDirectoryTree, sshSessionId]);
// When currentPath changes externally (grid navigation), ensure the parent
// directory is loaded in the tree so the selection highlight can appear.
useEffect(() => {
if (!sshSessionId || currentPath === "/") return;
const parentPath =
currentPath.substring(0, currentPath.lastIndexOf("/")) || "/";
const findByPath = (items: SidebarItem[]): SidebarItem | null => {
for (const item of items) {
if (item.path === parentPath) return item;
if (item.children) {
const found = findByPath(item.children);
if (found) return found;
}
}
return null;
};
const parent = findByPath(directoryTree);
if (parent && !loadedFoldersRef.current.has(parent.path)) {
void loadSubdirectory(parent.id, parent.path);
}
}, [currentPath, directoryTree, loadSubdirectory, sshSessionId]);
// ─── Quick-access mutation handlers ──────────────────────────────────────────
const handleRemoveRecentFile = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removeRecentFile(currentHost.id, item.path);
loadQuickAccessData();
toast.success(
t("fileManager.removedFromRecentFiles", { name: item.name }),
);
} catch (error) {
console.error("Failed to remove recent file:", error);
toast.error(t("fileManager.removeFailed"));
}
};
const handleUnpinFile = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removePinnedFile(currentHost.id, item.path);
loadQuickAccessData();
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
} catch (error) {
console.error("Failed to unpin file:", error);
toast.error(t("fileManager.unpinFailed"));
}
};
const handleRemoveShortcut = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removeFolderShortcut(currentHost.id, item.path);
loadQuickAccessData();
toast.success(t("fileManager.removedShortcut", { name: item.name }));
} catch (error) {
console.error("Failed to remove shortcut:", error);
toast.error(t("fileManager.removeShortcutFailed"));
}
};
const handleClearAllRecent = async () => {
if (!currentHost?.id || recentItems.length === 0) return;
try {
await Promise.all(
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
);
loadQuickAccessData();
toast.success(t("fileManager.clearedAllRecentFiles"));
} catch (error) {
console.error("Failed to clear recent files:", error);
toast.error(t("fileManager.clearFailed"));
}
};
// ─── Quick-access item click ──────────────────────────────────────────────────
const handleQuickAccessClick = (item: SidebarItem) => {
if (item.type === "recent" || item.type === "pinned") {
if (onFileOpen) {
onFileOpen(item);
} else {
const directory =
item.path.substring(0, item.path.lastIndexOf("/")) || "/";
onPathChange(directory);
}
} else if (item.type === "shortcut") {
onPathChange(item.path);
}
};
// ─── FolderTree directory selection (onSelect callback) ──────────────────────
/**
* Called by FolderTree whenever the user selects (clicks) a tree item.
* We navigate to the folder and lazily load children on first visit.
*/
const handleDirectorySelect = useCallback(
async (id: string) => {
// Walk the tree to find the item by id
const findItem = (items: SidebarItem[]): SidebarItem | null => {
for (const item of items) {
if (item.id === id) return item;
if (item.children) {
const found = findItem(item.children);
if (found) return found;
}
}
return null;
};
const item = findItem(directoryTree);
if (!item) return;
// Navigate to path
onPathChange(item.path);
// Lazy-load children the first time this folder is expanded
if (
sshSessionId &&
item.path !== "/" &&
!loadedFoldersRef.current.has(item.path)
) {
await loadSubdirectory(id, item.path);
}
},
[directoryTree, onPathChange, sshSessionId, loadSubdirectory],
);
// ─── Context menu ─────────────────────────────────────────────────────────────
const findTreeItemById = useCallback(
(items: SidebarItem[], id: string): SidebarItem | null => {
for (const item of items) {
if (item.id === id) return item;
if (item.children) {
const found = findTreeItemById(item.children, id);
if (found) return found;
}
}
return null;
},
[],
);
const handleItemContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
e.preventDefault();
e.stopPropagation();
if (onItemContextMenu) {
onItemContextMenu(e, item);
return;
}
setContextMenu({ x: e.clientX, y: e.clientY, isVisible: true, item });
};
const handleTreeContextMenu = (e: React.MouseEvent) => {
const target = e.target as HTMLElement;
const row = target.closest<HTMLElement>("[data-id]");
if (!row) return;
const id = row.getAttribute("data-id");
if (!id) return;
const item = findTreeItemById(directoryTree, id);
if (!item || item.type !== "folder") return;
handleItemContextMenu(e, item);
};
const closeContextMenu = () => {
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
};
useEffect(() => {
if (!contextMenu.isVisible || onItemContextMenu) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
const menuElement = document.querySelector("[data-sidebar-context-menu]");
if (!menuElement?.contains(target)) closeContextMenu();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeContextMenu();
};
const timeoutId = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
}, 50);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [contextMenu.isVisible, onItemContextMenu]);
// ─── Derive selected tree node + ancestors from currentPath ──────────────────
const { selectedTreeId, ancestorIds } = useMemo(() => {
if (currentPath === "/")
return { selectedTreeId: "root", ancestorIds: new Set<string>() };
const ancestors: string[] = [];
const findByPath = (
items: SidebarItem[],
path: string[],
): string | null => {
for (const item of items) {
if (item.path === currentPath) {
ancestors.push(...path, "root");
return item.id;
}
if (item.children) {
const found = findByPath(item.children, [...path, item.id]);
if (found) return found;
}
}
return null;
};
const id = findByPath(directoryTree, []);
return { selectedTreeId: id, ancestorIds: new Set(ancestors) };
}, [currentPath, directoryTree]);
// ─── Render helpers ───────────────────────────────────────────────────────────
/**
* Recursively renders directory tree items using FolderTree.Item + Content.
*
* FolderTree.Item detects "has children" via React.Children.count > 0.
* By always wrapping children in <FolderTree.Content> (even when the
* children array is empty), every directory shows the expand chevron.
* FolderTree.Content internally shows nothing when its own children are
* absent, so an unloaded folder simply expands to an empty state while
* the async fetch fills it in.
*/
const renderFolderTreeItem = (item: SidebarItem): React.ReactNode => (
<FolderTree.Item
key={item.id}
id={item.id}
label={item.name}
className={density === "compact" ? "py-0.5" : undefined}
>
<FolderTree.Content>
{item.children?.map((child) => renderFolderTreeItem(child))}
</FolderTree.Content>
</FolderTree.Item>
);
const renderQuickAccessItem = (item: SidebarItem, icon: React.ReactNode) => {
const dirPath =
item.type === "shortcut"
? item.path
: item.path.substring(0, item.path.lastIndexOf("/")) || "/";
const isActive = currentPath === dirPath;
return (
<button
key={item.id}
className={cn(
"w-full flex items-center gap-2.5 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wider transition-colors text-left border-l-2",
isActive
? "bg-accent-brand/10 text-accent-brand border-accent-brand"
: "text-muted-foreground hover:text-foreground hover:bg-muted border-transparent",
)}
onClick={() => handleQuickAccessClick(item)}
onContextMenu={(e) => handleItemContextMenu(e, item)}
title={item.path}
>
<div className="shrink-0">{icon}</div>
<span className="flex-1 truncate">{item.name}</span>
</button>
);
};
const renderSection = (
title: string,
items: SidebarItem[],
renderItem: (item: SidebarItem) => React.ReactNode,
) => {
if (items.length === 0) return null;
return (
<div>
<div className="px-3 py-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{title}
</span>
</div>
{items.map((item) => renderItem(item))}
</div>
);
};
const hasQuickAccessItems =
recentItems.length > 0 || pinnedItems.length > 0 || shortcuts.length > 0;
// ─── Render ───────────────────────────────────────────────────────────────────
const storageFilesystems = diskInfo?.filesystems ?? [];
const pathFs = mountForPath(storageFilesystems, currentPath);
const activeStorageMount =
selectedMount ?? pathFs?.mount ?? diskInfo?.mount ?? null;
const selectedFs =
selectedMount !== null
? (storageFilesystems.find((fs) => fs.mount === selectedMount) ?? null)
: pathFs;
const storage =
selectedFs && selectedFs.percent !== null
? {
percent: selectedFs.percent,
usedHuman: selectedFs.usedHuman ?? "N/A",
totalHuman: selectedFs.totalHuman ?? "N/A",
}
: diskInfo && diskInfo.percent !== null
? {
percent: diskInfo.percent,
usedHuman: diskInfo.usedHuman,
totalHuman: diskInfo.totalHuman,
}
: null;
return (
<>
<div className="h-full flex flex-col bg-card overflow-hidden">
<div className="flex-1 overflow-y-auto thin-scrollbar">
{onOpenTrash && (
<button
className="w-full flex items-center gap-2.5 px-3 py-2 text-[11px] font-bold uppercase tracking-wider text-muted-foreground hover:text-foreground hover:bg-muted border-l-2 border-transparent"
onClick={onOpenTrash}
>
<Trash2 className="size-3.5 shrink-0" />
<span>{t("fileManager.trash")}</span>
</button>
)}
{/* ── Recent files ──────────────────────────────────────── */}
{renderSection(t("fileManager.recent"), recentItems, (item) =>
renderQuickAccessItem(
item,
<File
className={cn(
"size-3.5 shrink-0",
currentPath ===
(item.path.substring(0, item.path.lastIndexOf("/")) || "/")
? "text-accent-brand"
: "text-muted-foreground/60",
)}
/>,
),
)}
{/* ── Pinned files ───────────────────────────────────────── */}
{renderSection(t("fileManager.pinned"), pinnedItems, (item) =>
renderQuickAccessItem(
item,
<Star
className={cn(
"size-3.5 shrink-0",
currentPath ===
(item.path.substring(0, item.path.lastIndexOf("/")) || "/")
? "text-accent-brand fill-accent-brand"
: "text-muted-foreground/60",
)}
/>,
),
)}
{/* ── Folder shortcuts ───────────────────────────────────── */}
{renderSection(t("fileManager.folderShortcuts"), shortcuts, (item) =>
renderQuickAccessItem(
item,
<Folder
className={cn(
"size-3.5 shrink-0",
currentPath === item.path
? "text-accent-brand"
: "text-muted-foreground/60",
)}
/>,
),
)}
{/* ── Directory tree ─────────────────────────────────────── */}
<div
className={cn(
hasQuickAccessItems && "border-t border-border mt-1 pt-1",
)}
>
<div className="px-3 py-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("fileManager.directories")}
</span>
</div>
<div className="px-1">
<div onContextMenu={handleTreeContextMenu}>
<FolderTree.Root
id="sidebar-directory-tree"
defaultExpanded={["root"]}
selectedId={selectedTreeId}
expandedIds={ancestorIds}
onSelect={(id) => handleDirectorySelect(id)}
className="bg-transparent border-0 rounded-none shadow-none"
>
{directoryTree.map((item) => renderFolderTreeItem(item))}
</FolderTree.Root>
</div>
</div>
</div>
{/* ── Storage — mobile only (inside scroll) ──────────────── */}
{storage && (
<div className="md:hidden">
<div className="border-t border-border mx-0 my-2" />
<div className="px-3 py-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("fileManager.storage")}
</span>
</div>
<div className="px-3 pb-3">
<StorageMeter
storage={storage}
filesystems={storageFilesystems}
selectedMount={activeStorageMount}
onSelectMount={setSelectedMount}
/>
</div>
</div>
)}
</div>
{/* ── Storage — desktop only (bottom of sidebar) ──────────── */}
{storage && (
<div className="hidden md:flex flex-col p-3 gap-2 border-t border-border shrink-0">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("fileManager.storage")}
</span>
<StorageMeter
storage={storage}
filesystems={storageFilesystems}
selectedMount={activeStorageMount}
onSelectMount={setSelectedMount}
/>
</div>
)}
</div>
{/* ── Context menu (fallback when parent does not supply onItemContextMenu) */}
{!onItemContextMenu && contextMenu.isVisible && contextMenu.item && (
<>
<div className="fixed inset-0 z-40" />
<div
data-sidebar-context-menu
className="fixed bg-card border border-border rounded-none shadow-xl min-w-[180px] z-50 overflow-hidden"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
{contextMenu.item.type === "recent" && (
<>
<button
className="w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center gap-2.5 hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={() => {
handleRemoveRecentFile(contextMenu.item!);
closeContextMenu();
}}
>
<Clock className="size-3.5 shrink-0" />
<span className="flex-1">
{t("fileManager.removeFromRecentFiles")}
</span>
</button>
{recentItems.length > 1 && (
<>
<div className="border-t border-border" />
<button
className="w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center gap-2.5 text-destructive hover:bg-destructive/10 transition-colors"
onClick={() => {
handleClearAllRecent();
closeContextMenu();
}}
>
<Clock className="size-3.5 shrink-0" />
<span className="flex-1">
{t("fileManager.clearAllRecentFiles")}
</span>
</button>
</>
)}
</>
)}
{contextMenu.item.type === "pinned" && (
<button
className="w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center gap-2.5 hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={() => {
handleUnpinFile(contextMenu.item!);
closeContextMenu();
}}
>
<Star className="size-3.5 shrink-0" />
<span className="flex-1">{t("fileManager.unpinFile")}</span>
</button>
)}
{contextMenu.item.type === "shortcut" && (
<button
className="w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center gap-2.5 hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={() => {
handleRemoveShortcut(contextMenu.item!);
closeContextMenu();
}}
>
<Bookmark className="size-3.5 shrink-0" />
<span className="flex-1">
{t("fileManager.removeShortcut")}
</span>
</button>
)}
</div>
</>
)}
</>
);
}
@@ -0,0 +1,536 @@
import React, { useEffect, useRef, useState } from "react";
import {
ArrowUp,
ChevronLeft,
ChevronRight,
Copy,
FilePlus,
Folder,
FolderPlus,
Grid3X3,
Laptop,
Layout,
List,
Plus,
RefreshCw,
Rows3,
Search,
Trash2,
Upload,
} from "lucide-react";
import {
Button,
Input,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@termix/plugin-sdk/ui";
import type { FileItem } from "./host-types";
type SortBy = "name" | "modified" | "size";
type SortOrder = "asc" | "desc";
type ViewMode = "grid" | "list";
type Density = "comfortable" | "compact";
type FileManagerToolbarProps = {
t: (key: string) => string;
currentPath: string;
navIndex: number;
navHistoryLength: number;
isLoading: boolean;
sshSessionId: string | null;
selectedFiles: FileItem[];
searchQuery: string;
setSearchQuery: (query: string) => void;
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
density: Density;
setDensity: (density: Density) => void;
sortBy: SortBy;
setSortBy: (sortBy: SortBy) => void;
sortOrder: SortOrder;
setSortOrder: (sortOrder: SortOrder) => void;
setMobileSidebarOpen: (updater: (open: boolean) => boolean) => void;
goBack: () => void;
goForward: () => void;
goUp: () => void;
navigateTo: (path: string) => void;
handleRefreshDirectory: () => void;
handleDeleteFiles: (files: FileItem[]) => void;
handleCopyFiles: (files: FileItem[]) => void;
handleFilesDropped: (fileList: FileList) => void;
handleCreateNewFolder: () => void;
handleCreateNewFile: () => void;
/** Desktop app only: show the Local | Remote split-view toggle. */
showLocalPaneToggle?: boolean;
localPaneOpen?: boolean;
onToggleLocalPane?: () => void;
/** Desktop directories sidebar (mobile uses the overlay instead). */
sidebarOpen?: boolean;
onToggleSidebar?: () => void;
};
function Breadcrumb({
currentPath,
navigateTo,
t,
}: Pick<FileManagerToolbarProps, "currentPath" | "navigateTo" | "t">) {
return (
<>
<Folder className="size-3.5 text-accent-brand shrink-0" />
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">
{currentPath.split("/").map((part, i, arr) => (
<React.Fragment key={i}>
{part === "" && i === 0 ? (
<button
onClick={(e) => {
e.stopPropagation();
navigateTo("/");
}}
className="hover:text-accent-brand transition-colors"
>
{t("fileManager.root")}
</button>
) : part !== "" ? (
<button
onClick={(e) => {
e.stopPropagation();
navigateTo(arr.slice(0, i + 1).join("/") || "/");
}}
className="hover:text-accent-brand transition-colors"
>
{part}
</button>
) : null}
{i < arr.length - 1 && part !== "" && (
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
)}
{i === 0 && arr.length > 1 && part === "" && (
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
)}
</React.Fragment>
))}
</div>
</>
);
}
function PathBar({
currentPath,
navigateTo,
t,
className,
}: Pick<FileManagerToolbarProps, "currentPath" | "navigateTo" | "t"> & {
className: string;
}) {
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(currentPath);
const inputRef = useRef<HTMLInputElement>(null);
const doneRef = useRef(false);
useEffect(() => {
if (!isEditing) return;
doneRef.current = false;
const timer = setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
return () => clearTimeout(timer);
}, [isEditing]);
const commit = (path: string) => {
if (doneRef.current) return;
doneRef.current = true;
setIsEditing(false);
const trimmed = path.trim();
if (trimmed && trimmed !== currentPath) {
navigateTo(trimmed);
}
};
const cancel = () => {
if (doneRef.current) return;
doneRef.current = true;
setIsEditing(false);
};
if (isEditing) {
return (
<div className={className}>
<Folder className="size-3.5 text-accent-brand shrink-0" />
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit(value);
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
onBlur={() => commit(value)}
className="flex-1 min-w-0 bg-transparent text-xs font-semibold tracking-wide outline-none text-foreground"
/>
</div>
);
}
return (
<div
className={`${className} cursor-text`}
onClick={() => {
setValue(currentPath);
setIsEditing(true);
}}
>
<Breadcrumb currentPath={currentPath} navigateTo={navigateTo} t={t} />
</div>
);
}
export function FileManagerToolbar({
t,
currentPath,
navIndex,
navHistoryLength,
isLoading,
sshSessionId,
selectedFiles,
searchQuery,
setSearchQuery,
viewMode,
setViewMode,
density,
setDensity,
sortBy,
setSortBy,
sortOrder,
setSortOrder,
setMobileSidebarOpen,
goBack,
goForward,
goUp,
navigateTo,
handleRefreshDirectory,
handleDeleteFiles,
handleCopyFiles,
handleFilesDropped,
handleCreateNewFolder,
handleCreateNewFile,
showLocalPaneToggle = false,
localPaneOpen = false,
onToggleLocalPane,
sidebarOpen = true,
onToggleSidebar,
}: FileManagerToolbarProps) {
const searchInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleSearchShortcut = (event: KeyboardEvent) => {
if (
event.defaultPrevented ||
!(event.ctrlKey || event.metaKey) ||
event.altKey ||
event.shiftKey ||
event.key.toLowerCase() !== "f"
)
return;
const input = searchInputRef.current;
if (!input) return;
const visible =
typeof input.checkVisibility === "function"
? input.checkVisibility({ visibilityProperty: true })
: input.offsetParent !== null;
if (!visible) return;
const active = document.activeElement;
if (
active !== input &&
active?.closest(
'input, textarea, [contenteditable="true"], [role="dialog"]',
)
)
return;
event.preventDefault();
input.focus();
input.select();
};
document.addEventListener("keydown", handleSearchShortcut);
return () => document.removeEventListener("keydown", handleSearchShortcut);
}, []);
return (
<div className="flex flex-col shrink-0 mx-3 mt-3 border border-border bg-card">
<div className="flex flex-row items-center justify-between px-3 py-2 gap-2">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => {
// Below md the sidebar is an overlay; above it, a persisted
// show/hide of the directories panel.
const isDesktop =
typeof window !== "undefined" &&
window.matchMedia("(min-width: 768px)").matches;
if (isDesktop && onToggleSidebar) onToggleSidebar();
else setMobileSidebarOpen((open) => !open);
}}
className={`size-8 rounded-none ${sidebarOpen ? "" : "md:bg-accent-brand/10 md:text-accent-brand"}`}
title={t("fileManager.toggleSidebar")}
aria-pressed={!sidebarOpen}
>
<Layout className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goBack}
disabled={navIndex <= 0}
className="size-8 rounded-none"
>
<ChevronLeft className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goForward}
disabled={navIndex >= navHistoryLength - 1}
className="size-8 rounded-none"
>
<ChevronRight className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={goUp}
disabled={currentPath === "/"}
className="size-8 rounded-none"
>
<ArrowUp className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleRefreshDirectory}
className="size-8 rounded-none"
>
<RefreshCw
className={`size-4 ${isLoading && !!sshSessionId ? "animate-spin [animation-duration:0.5s]" : ""}`}
/>
</Button>
</div>
<PathBar
currentPath={currentPath}
navigateTo={navigateTo}
t={t}
className="hidden md:flex flex-1 items-center px-3 h-8 bg-muted/50 border border-border rounded-none gap-2 overflow-hidden"
/>
<div className="flex items-center gap-2">
{selectedFiles.length > 0 && (
<div className="flex items-center gap-1 px-2 py-1 bg-accent-brand/10 border border-accent-brand/20 text-accent-brand text-[10px] font-black uppercase tracking-tighter">
<Button
variant="ghost"
size="icon"
className="size-6 text-accent-brand hover:bg-accent-brand/20 rounded-none"
onClick={() => handleDeleteFiles(selectedFiles)}
>
<Trash2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-6 text-accent-brand hover:bg-accent-brand/20 rounded-none"
onClick={() => handleCopyFiles(selectedFiles)}
>
<Copy className="size-3.5" />
</Button>
</div>
)}
<div className="relative w-28 md:w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder={t("fileManager.searchFiles")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8 pl-8 text-xs bg-muted/50 border-border rounded-none focus:ring-1 focus:ring-accent-brand/50"
/>
</div>
{showLocalPaneToggle && (
<Button
variant={localPaneOpen ? "secondary" : "ghost"}
size="icon"
onClick={onToggleLocalPane}
title={
localPaneOpen
? t("fileManager.hideLocalFiles")
: t("fileManager.showLocalFiles")
}
aria-pressed={localPaneOpen}
className={`hidden md:inline-flex size-8 rounded-none border border-border ${localPaneOpen ? "bg-accent-brand/10 text-accent-brand border-accent-brand/40" : ""}`}
>
<Laptop className="size-4" />
</Button>
)}
<div className="flex items-center border border-border rounded-none overflow-hidden">
<Button
variant={viewMode === "grid" ? "secondary" : "ghost"}
size="icon"
onClick={() => setViewMode("grid")}
className={`size-8 rounded-none ${viewMode === "grid" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
>
<Grid3X3 className="size-4" />
</Button>
<Button
variant={viewMode === "list" ? "secondary" : "ghost"}
size="icon"
onClick={() => setViewMode("list")}
className={`size-8 rounded-none border-l border-border ${viewMode === "list" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
>
<List className="size-4" />
</Button>
<Button
variant={density === "compact" ? "secondary" : "ghost"}
size="icon"
onClick={() =>
setDensity(density === "compact" ? "comfortable" : "compact")
}
className={`size-8 rounded-none border-l border-border ${density === "compact" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
title={t(
density === "compact"
? "fileManager.comfortableLayout"
: "fileManager.compactLayout",
)}
>
<Rows3 className="size-4" />
</Button>
</div>
<label
className="hidden md:block cursor-pointer"
title={t("fileManager.upload")}
>
<input
type="file"
multiple
className="hidden"
onChange={(e) => {
const files = e.target.files;
if (files) handleFilesDropped(files);
}}
/>
<div className="h-8 px-3 flex items-center gap-1.5 border border-border hover:bg-muted text-muted-foreground hover:text-foreground transition-colors text-[10px] font-bold uppercase tracking-widest">
<Upload className="size-3.5" /> {t("fileManager.upload")}
</div>
</label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none font-bold uppercase tracking-widest text-[10px]"
>
<Plus className="size-3.5" />
{t("fileManager.new")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-44 rounded-none border-border bg-card"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem
onSelect={() => {
setTimeout(() => handleCreateNewFolder(), 0);
}}
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
>
<FolderPlus className="size-4 text-accent-brand" />
{t("fileManager.newFolder")}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setTimeout(() => handleCreateNewFile(), 0);
}}
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
>
<FilePlus className="size-4 text-muted-foreground" />
{t("fileManager.newFile")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-[10px] uppercase tracking-widest text-muted-foreground py-1">
{t("fileManager.sortBy")}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={sortBy}
onValueChange={(value) => setSortBy(value as SortBy)}
>
<DropdownMenuRadioItem
value="name"
className="rounded-none text-xs"
>
{t("fileManager.sortByName")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="modified"
className="rounded-none text-xs"
>
{t("fileManager.sortByDate")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="size"
className="rounded-none text-xs"
>
{t("fileManager.sortBySize")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={sortOrder}
onValueChange={(value) => setSortOrder(value as SortOrder)}
>
<DropdownMenuRadioItem
value="asc"
className="rounded-none text-xs"
>
{t("fileManager.ascending")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem
value="desc"
className="rounded-none text-xs"
>
{t("fileManager.descending")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="md:hidden flex items-center px-3 pb-2 gap-2">
<PathBar
currentPath={currentPath}
navigateTo={navigateTo}
t={t}
className="flex-1 flex items-center px-3 h-8 bg-muted/50 border border-border gap-2 overflow-hidden"
/>
</div>
</div>
);
}
@@ -0,0 +1,214 @@
import { useCallback, useEffect, useState } from "react";
import { RotateCcw, Trash2 } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { toast } from "sonner";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@termix/plugin-sdk/ui";
import {
emptySSHTrash,
getSSHTrash,
permanentlyDeleteSSHTrashItem,
restoreSSHTrashItem,
updateSSHTrashRetention,
type TrashItem,
} from "./api/ssh-file-operations-api";
export function FileManagerTrashDialog({
open,
onOpenChange,
sessionId,
onChanged,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
sessionId: string | null;
onChanged: () => void;
}) {
const { t } = useTranslation();
const [items, setItems] = useState<TrashItem[]>([]);
const [retentionDays, setRetentionDays] = useState(7);
const [canManageRetention, setCanManageRetention] = useState(false);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
if (!sessionId) return;
setLoading(true);
try {
const result = await getSSHTrash(sessionId);
setItems(result.items);
setRetentionDays(result.retentionDays);
setCanManageRetention(result.canManageRetention);
} catch {
toast.error(t("fileManager.trashLoadFailed"));
} finally {
setLoading(false);
}
}, [sessionId, t]);
useEffect(() => {
if (open) void load();
}, [load, open]);
async function restore(item: TrashItem) {
if (!sessionId) return;
try {
await restoreSSHTrashItem(sessionId, item.id);
setItems((current) => current.filter((entry) => entry.id !== item.id));
onChanged();
toast.success(t("fileManager.trashRestored", { name: item.name }));
} catch {
toast.error(t("fileManager.trashRestoreFailed"));
}
}
async function remove(item: TrashItem) {
if (!sessionId) return;
if (
!window.confirm(t("fileManager.trashDeleteConfirm", { name: item.name }))
)
return;
try {
await permanentlyDeleteSSHTrashItem(sessionId, item.id);
setItems((current) => current.filter((entry) => entry.id !== item.id));
toast.success(
t("fileManager.trashPermanentlyDeleted", { name: item.name }),
);
} catch {
toast.error(t("fileManager.trashDeleteFailed"));
}
}
async function empty() {
if (!sessionId || items.length === 0) return;
if (!window.confirm(t("fileManager.trashEmptyConfirm"))) return;
try {
await emptySSHTrash(sessionId);
setItems([]);
toast.success(t("fileManager.trashEmptied"));
} catch {
toast.error(t("fileManager.trashDeleteFailed"));
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[85dvh] w-[calc(100vw-2rem)] flex-col sm:max-w-3xl">
<DialogHeader className="shrink-0 pr-8">
<DialogTitle>{t("fileManager.trash")}</DialogTitle>
<DialogDescription>
{t("fileManager.trashDescription", { days: retentionDays })}{" "}
<a
href="https://docs.termix.site/features/files-and-hosts/trash"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</DialogDescription>
</DialogHeader>
{canManageRetention && (
<div className="flex shrink-0 items-center gap-2 border border-border p-2 text-xs">
<span className="flex-1">{t("fileManager.trashRetention")}</span>
<input
type="number"
min={1}
max={3650}
value={retentionDays}
onChange={(event) => setRetentionDays(Number(event.target.value))}
className="h-8 w-20 border border-border bg-background px-2"
/>
<Button
size="sm"
variant="outline"
onClick={async () => {
if (!sessionId || retentionDays < 1 || retentionDays > 3650)
return;
try {
await updateSSHTrashRetention(sessionId, retentionDays);
toast.success(t("fileManager.trashRetentionSaved"));
} catch {
toast.error(t("fileManager.trashRetentionFailed"));
}
}}
>
{t("common.save")}
</Button>
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto border border-border">
{loading ? (
<div className="p-6 text-center text-sm text-muted-foreground">
{t("common.loading")}
</div>
) : items.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">
{t("fileManager.trashEmpty")}
</div>
) : (
items.map((item) => (
<div
key={item.id}
className="flex flex-col gap-2 border-b border-border p-3 last:border-b-0 sm:flex-row sm:items-center sm:gap-3"
>
<Trash2 className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-medium"
title={item.name}
>
{item.name}
</div>
<div
className="truncate text-xs text-muted-foreground"
title={item.originalPath}
>
{item.originalPath}
</div>
<div className="text-xs text-muted-foreground">
{new Date(item.deletedAt).toLocaleString()}
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button
size="sm"
variant="outline"
onClick={() => void restore(item)}
>
<RotateCcw className="mr-1 size-3.5" />
{t("fileManager.trashRestore")}
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => void remove(item)}
>
{t("fileManager.trashDeletePermanently")}
</Button>
</div>
</div>
))
)}
</div>
<div className="flex shrink-0 justify-end">
<Button
variant="destructive"
disabled={items.length === 0}
onClick={() => void empty()}
>
{t("fileManager.trashEmptyAction")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,322 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@termix/plugin-sdk/ui";
import {
Clipboard,
Edit3,
Eye,
EyeOff,
ExternalLink,
FilePlus,
FolderOpen,
FolderPlus,
RefreshCw,
Trash2,
Upload,
} from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Kbd, KbdKey, KbdSeparator } from "@termix/plugin-sdk/ui";
import type { LocalFileEntry } from "@termix/plugin-sdk/frontend";
const VIEWPORT_PADDING = 16;
export interface LocalFileContextMenuProps {
x: number;
y: number;
/** Entries the menu acts on; empty means the pane background was clicked. */
entries: LocalFileEntry[];
isVisible: boolean;
showHidden: boolean;
canUpload: boolean;
onClose: () => void;
onOpen: (entry: LocalFileEntry) => void;
onUploadToRemote: (entries: LocalFileEntry[]) => void;
onReveal: (entry?: LocalFileEntry) => void;
onRename: (entry: LocalFileEntry) => void;
onCopyPath: (entries: LocalFileEntry[]) => void;
onNewFolder: () => void;
onNewFile: () => void;
onRefresh: () => void;
onToggleHidden: () => void;
onDelete: (entries: LocalFileEntry[]) => void;
}
interface MenuItem {
icon?: React.ReactNode;
label?: string;
action?: () => void;
shortcut?: string;
separator?: boolean;
disabled?: boolean;
danger?: boolean;
}
/**
* Right-click menu for the local pane. Same chrome and dismissal rules as
* FileManagerContextMenu, with the subset of actions that make sense for the
* user's own disk.
*/
export function LocalFileContextMenu({
x,
y,
entries,
isVisible,
showHidden,
canUpload,
onClose,
onOpen,
onUploadToRemote,
onReveal,
onRename,
onCopyPath,
onNewFolder,
onNewFile,
onRefresh,
onToggleHidden,
onDelete,
}: LocalFileContextMenuProps) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
const [menuPosition, setMenuPosition] = useState({ x, y });
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
if (!isVisible) {
setIsMounted(false);
return;
}
setIsMounted(true);
let cleanupFn: (() => void) | null = null;
const timeoutId = setTimeout(() => {
const handleClickOutside = (event: MouseEvent) => {
if (!menuRef.current?.contains(event.target as Node)) onClose();
};
const handleRightClick = (event: MouseEvent) => {
event.preventDefault();
onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
};
const handleBlur = () => onClose();
const handleScroll = () => onClose();
document.addEventListener("mousedown", handleClickOutside, true);
document.addEventListener("contextmenu", handleRightClick);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("blur", handleBlur);
window.addEventListener("scroll", handleScroll, true);
cleanupFn = () => {
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("contextmenu", handleRightClick);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("blur", handleBlur);
window.removeEventListener("scroll", handleScroll, true);
};
}, 50);
return () => {
clearTimeout(timeoutId);
cleanupFn?.();
};
}, [isVisible, x, y, onClose]);
useLayoutEffect(() => {
if (!isVisible || !menuRef.current) return;
const menuWidth = menuRef.current.offsetWidth;
const menuHeight = menuRef.current.offsetHeight;
let adjustedX = x;
let adjustedY = y;
if (x + menuWidth > window.innerWidth) {
adjustedX = window.innerWidth - menuWidth - 10;
}
if (y + menuHeight > window.innerHeight) {
adjustedY = Math.max(10, window.innerHeight - menuHeight - 10);
}
setMenuPosition({ x: Math.max(8, adjustedX), y: Math.max(8, adjustedY) });
}, [isVisible, x, y, entries.length]);
const isEntryContext = entries.length > 0;
const isSingle = entries.length === 1;
const single = isSingle ? entries[0] : null;
const menuItems: MenuItem[] = [];
if (isEntryContext) {
if (single) {
menuItems.push({
icon: <ExternalLink className="size-3.5" />,
label:
single.type === "directory"
? t("fileManager.localOpenFolder")
: t("fileManager.localOpen"),
action: () => onOpen(single),
shortcut: "Enter",
});
}
menuItems.push({
icon: <Upload className="size-3.5" />,
label: isSingle
? t("fileManager.localUploadToRemote")
: t("fileManager.localUploadToRemoteMany", { count: entries.length }),
action: () => onUploadToRemote(entries),
disabled: !canUpload,
});
menuItems.push({
icon: <FolderOpen className="size-3.5" />,
label: t("fileManager.localRevealInFileManager"),
action: () => onReveal(entries[0]),
});
menuItems.push({ separator: true });
if (single) {
menuItems.push({
icon: <Edit3 className="size-3.5" />,
label: t("fileManager.rename"),
action: () => onRename(single),
shortcut: "F2",
});
}
menuItems.push({
icon: <Clipboard className="size-3.5" />,
label: isSingle ? t("fileManager.copyPath") : t("fileManager.copyPaths"),
action: () => onCopyPath(entries),
});
menuItems.push({ separator: true });
menuItems.push({
icon: <Trash2 className="size-3.5" />,
label: isSingle
? t("fileManager.localMoveToTrash")
: t("fileManager.localMoveToTrashMany", { count: entries.length }),
action: () => onDelete(entries),
shortcut: "Del",
danger: true,
});
} else {
menuItems.push({
icon: <FolderPlus className="size-3.5" />,
label: t("fileManager.newFolder"),
action: onNewFolder,
});
menuItems.push({
icon: <FilePlus className="size-3.5" />,
label: t("fileManager.newFile"),
action: onNewFile,
});
menuItems.push({ separator: true });
menuItems.push({
icon: <FolderOpen className="size-3.5" />,
label: t("fileManager.localRevealInFileManager"),
action: () => onReveal(),
});
menuItems.push({
icon: showHidden ? (
<EyeOff className="size-3.5" />
) : (
<Eye className="size-3.5" />
),
label: showHidden
? t("fileManager.localHideHidden")
: t("fileManager.localShowHidden"),
action: onToggleHidden,
});
menuItems.push({
icon: <RefreshCw className="size-3.5" />,
label: t("fileManager.refresh"),
action: onRefresh,
shortcut: "F5",
});
}
const renderShortcut = (shortcut: string) => {
const keys = shortcut.split("+");
if (keys.length === 1) return <Kbd>{keys[0]}</Kbd>;
return (
<Kbd>
{keys.map((key, index) => (
<React.Fragment key={`${key}-${index}`}>
<KbdKey>{key}</KbdKey>
{index < keys.length - 1 && <KbdSeparator />}
</React.Fragment>
))}
</Kbd>
);
};
if (!isVisible && !isMounted) return null;
return (
<>
<div
className={cn(
"fixed inset-0 z-[99990] transition-opacity duration-150",
!isMounted && "opacity-0",
)}
/>
<div
ref={menuRef}
data-context-menu
data-testid="local-file-context-menu"
className="fixed bg-card border border-border rounded-none shadow-md min-w-[220px] max-w-[300px] z-[99995] overflow-x-hidden overflow-y-auto py-1"
style={{
left: menuPosition.x,
top: menuPosition.y,
maxHeight: `calc(100vh - ${VIEWPORT_PADDING * 2}px)`,
}}
>
{menuItems.map((item, index) => {
if (item.separator) {
return (
<div
key={`separator-${index}`}
className="my-1 border-t border-border"
/>
);
}
return (
<button
key={`${item.label}-${index}`}
type="button"
className={cn(
"w-full px-3 min-h-8 py-1.5 text-left text-xs font-semibold flex items-center justify-between gap-3 rounded-none transition-colors cursor-pointer",
"hover:bg-accent-brand/10 hover:text-accent-brand",
item.disabled &&
"opacity-40 cursor-not-allowed hover:bg-transparent hover:text-current",
item.danger &&
"text-destructive hover:bg-destructive/10 hover:text-destructive",
)}
onClick={() => {
if (item.disabled) return;
item.action?.();
onClose();
}}
disabled={item.disabled}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-shrink-0 text-muted-foreground">
{item.icon}
</div>
<span className="flex-1 leading-tight">{item.label}</span>
</div>
{item.shortcut && (
<div className="ml-auto flex-shrink-0 opacity-50">
{renderShortcut(item.shortcut)}
</div>
)}
</button>
);
})}
</div>
</>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,98 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
Button,
PasswordInput,
} from "@termix/plugin-sdk/ui";
import { Shield } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
interface SudoPasswordDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (password: string) => void;
}
export function SudoPasswordDialog({
open,
onOpenChange,
onSubmit,
}: SudoPasswordDialogProps) {
const { t } = useTranslation();
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) {
setPassword("");
setLoading(false);
}
}, [open]);
const handleSubmit = async (e?: React.FormEvent) => {
if (e) {
e.preventDefault();
}
if (!password.trim()) {
return;
}
setLoading(true);
onSubmit(password);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-md rounded-none border-border bg-card">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<Shield className="size-4 text-accent-brand" />
{t("fileManager.sudoPasswordRequired")}
</DialogTitle>
<DialogDescription className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">
{t("fileManager.enterSudoPassword")}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("fileManager.sudoPassword")}
className="rounded-none bg-muted/50 border-border text-xs focus:ring-1 focus:ring-accent-brand/50"
autoFocus
disabled={loading}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={loading}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={!password.trim() || loading}
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{loading ? t("common.loading") : t("common.confirm")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,91 @@
import type { TranslateFn } from "@termix/plugin-sdk/frontend";
import type { TranslateFn as TFunction } from "@termix/plugin-sdk/frontend";
import { getTransferStatus, listActiveTransfers } from "./api/transfer-api";
import { createFormatTransferMetrics } from "./transferMetricsFormat.ts";
import {
beginTransferProgressMonitoring,
isTransferBeingMonitored,
showTransferCompletionToast,
} from "./transferProgressMonitor.tsx";
import {
clearStalePendingTransfer,
getPendingTransferIds,
isTransferNotified,
} from "./transferNotificationStore.ts";
import { runAdaptivePolling } from "@termix/plugin-sdk/ui";
const POLL_INTERVAL_MS = 2000;
/**
* Resumes progress toasts for in-flight host-to-host transfers across app
* reloads and tab switches. No UI of its own, so it runs as plain background
* polling from activate() rather than a mounted headless component - core
* cannot mount a plugin component outside a tab/panel/slot, and this needs
* nothing from one.
*/
export function startTransferMonitor(translate: TranslateFn): () => void {
// Every call here is a plain t("key") / t("key", {options}) - the calls
// these helpers make everywhere else, so the SDK's simpler translate
// function satisfies the same shape they're already typed against.
const t = translate as unknown as TFunction;
const formatTransferMetrics = createFormatTransferMetrics(t);
const reconcileTransfers = async () => {
let hasWork = false;
try {
const { transfers } = await listActiveTransfers();
hasWork = transfers.length > 0;
for (const transfer of transfers) {
if (isTransferBeingMonitored(transfer.transferId)) continue;
beginTransferProgressMonitoring(transfer.transferId, t, {
resumed: true,
initialStatus: transfer,
formatTransferMetrics,
});
}
} catch {
// Non-fatal: file-manager service may be unavailable briefly
}
const pendingTransferIds = getPendingTransferIds();
for (const transferId of pendingTransferIds) {
if (
isTransferBeingMonitored(transferId) ||
isTransferNotified(transferId)
) {
continue;
}
hasWork = true;
try {
const status = await getTransferStatus(transferId);
if (status.status === "running") {
if (!isTransferBeingMonitored(transferId)) {
beginTransferProgressMonitoring(transferId, t, {
resumed: true,
initialStatus: status,
formatTransferMetrics,
});
}
continue;
}
showTransferCompletionToast(
status,
t,
undefined,
formatTransferMetrics,
);
} catch {
clearStalePendingTransfer(transferId);
}
}
return hasWork;
};
return runAdaptivePolling(reconcileTransfers, {
minIntervalMs: POLL_INTERVAL_MS,
maxIntervalMs: 30_000,
stablePollsPerStep: 1,
});
}
@@ -0,0 +1,125 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
// Response bodies are typed at each call site's return type.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Body = any;
interface ClientResponse<T> {
data: T;
status: number;
headers: Record<string, unknown>;
}
/** The plugin client is axios underneath; these are the parts used here. */
export interface FileManagerClient {
get<T = Body>(url: string, config?: unknown): Promise<ClientResponse<T>>;
delete<T = Body>(url: string, config?: unknown): Promise<ClientResponse<T>>;
post<T = Body>(
url: string,
body?: unknown,
config?: unknown,
): Promise<ClientResponse<T>>;
put<T = Body>(
url: string,
body?: unknown,
config?: unknown,
): Promise<ClientResponse<T>>;
patch<T = Body>(
url: string,
body?: unknown,
config?: unknown,
): Promise<ClientResponse<T>>;
postForm<T = Body>(
url: string,
body?: unknown,
config?: unknown,
): Promise<ClientResponse<T>>;
}
let current: TermixApp | null = null;
/** Set in activate, cleared on deactivate. */
export function setFileManagerApp(app: TermixApp | null): void {
current = app;
}
function app(): TermixApp {
if (!current) throw new Error("The file manager plugin is not active");
return current;
}
export function listHosts(): ReturnType<TermixApp["listHosts"]> {
return app().listHosts();
}
export function fileManagerApi(): FileManagerClient {
return app().api as unknown as FileManagerClient;
}
// A live SSH session (keyed by sessionId) remembers which backend holds it,
// so every later call for that session reaches the same server.
const sessionOrigins = new Map<string, "local" | "remote">();
export function setSessionOrigin(
sessionId: string,
origin: "local" | "remote",
): void {
sessionOrigins.set(sessionId, origin);
}
export function clearSessionOrigin(sessionId: string): void {
sessionOrigins.delete(sessionId);
}
export function getSessionOrigin(sessionId: string): "local" | "remote" {
return sessionOrigins.get(sessionId) === "remote" ? "remote" : "local";
}
export function getFileManagerApiForSession(
sessionId: string,
): FileManagerClient {
return app().apiFor(
getSessionOrigin(sessionId),
) as unknown as FileManagerClient;
}
export function fileManagerApiFor(
origin: "local" | "remote" | undefined,
): FileManagerClient {
return app().apiFor(origin ?? "local") as unknown as FileManagerClient;
}
class ApiError extends Error {
constructor(
message: string,
public status?: number,
public code?: string,
) {
super(message);
this.name = "ApiError";
}
}
interface AxiosLikeError {
message?: string;
response?: {
status?: number;
data?: { message?: string; error?: string; code?: string };
};
}
/** Turns an axios failure into an Error carrying the server's message. */
export function handleApiError(error: unknown, operation: string): never {
const e = error as AxiosLikeError;
if (e?.response) {
const status = e.response.status;
const data = e.response.data ?? {};
const message =
data.message || data.error || e.message || `Failed to ${operation}`;
const apiError = new ApiError(message, status, data.code || data.error);
(apiError as ApiError & { response?: unknown }).response = e.response;
throw apiError;
}
if (error instanceof Error) throw error;
throw new ApiError(`Failed to ${operation}`);
}
@@ -0,0 +1,156 @@
import { fileManagerApi, handleApiError } from "./client";
/** Row shape shared by the recent / pinned / shortcut list endpoints. */
export interface FileManagerEntry {
id: number;
name: string;
path: string;
lastOpened?: string;
[key: string]: unknown;
}
// FILE MANAGER DATA
// ============================================================================
export async function getRecentFiles(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await fileManagerApi().get("/recent", {
params: { hostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get recent files");
throw error;
}
}
export async function addRecentFile(
hostId: number,
path: string,
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi().post("/recent", {
hostId,
path,
name,
});
return response.data;
} catch (error) {
handleApiError(error, "add recent file");
throw error;
}
}
export async function removeRecentFile(
hostId: number,
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi().delete("/recent", {
data: { hostId, path },
});
return response.data;
} catch (error) {
handleApiError(error, "remove recent file");
throw error;
}
}
export async function getPinnedFiles(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await fileManagerApi().get("/pinned", {
params: { hostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get pinned files");
throw error;
}
}
export async function addPinnedFile(
hostId: number,
path: string,
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi().post("/pinned", {
hostId,
path,
name,
});
return response.data;
} catch (error) {
handleApiError(error, "add pinned file");
throw error;
}
}
export async function removePinnedFile(
hostId: number,
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi().delete("/pinned", {
data: { hostId, path },
});
return response.data;
} catch (error) {
handleApiError(error, "remove pinned file");
throw error;
}
}
export async function getFolderShortcuts(
hostId: number,
): Promise<FileManagerEntry[]> {
try {
const response = await fileManagerApi().get("/shortcuts", {
params: { hostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get folder shortcuts");
throw error;
}
}
export async function addFolderShortcut(
hostId: number,
path: string,
name?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi().post("/shortcuts", {
hostId,
path,
name,
});
return response.data;
} catch (error) {
handleApiError(error, "add folder shortcut");
throw error;
}
}
export async function removeFolderShortcut(
hostId: number,
path: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi().delete("/shortcuts", {
data: { hostId, path },
});
return response.data;
} catch (error) {
handleApiError(error, "remove folder shortcut");
throw error;
}
}
// ============================================================================
@@ -0,0 +1,6 @@
import { listHosts } from "./client";
import type { SSHHost } from "../host-types";
export async function getSSHHosts(): Promise<SSHHost[]> {
return (await listHosts()) as unknown as SSHHost[];
}
@@ -0,0 +1,181 @@
// Local disk <-> remote SFTP transfers for the desktop app's dual-pane file
// manager. The bytes never pass through the renderer: the Electron main
// process streams them between disk and the backend's existing
// `uploadFileStream` / `downloadFileStream` routes (see
// electron/local-files.cjs), and we only orchestrate + relay progress here.
//
// The renderer deliberately does not choose the URL or the auth headers. It
// names the origin that owns the SSH session ("local" embedded backend or the
// configured Remote Sync server) and the main process resolves the rest, so a
// compromised renderer cannot turn the transfer bridge into an HTTP client.
import {
getLocalAuthToken,
type LocalTransferOrigin,
type LocalTransferProgress,
} from "@termix/plugin-sdk/frontend";
import { getSessionOrigin } from "./client";
import { getDeviceId } from "@termix/plugin-sdk/ui";
export interface LocalTransferProgressEvent {
transferred: number;
total?: number;
}
type ProgressListener = (event: LocalTransferProgressEvent) => void;
/** Error from the main process; `code` is e.g. "EEXIST" or "EBUSY". */
export class LocalTransferError extends Error {
code?: string;
constructor(message: string, code?: string) {
super(message);
this.name = "LocalTransferError";
this.code = code;
}
}
const progressListeners = new Map<string, ProgressListener>();
let unsubscribeProgress: (() => void) | null = null;
function ensureProgressSubscription() {
if (unsubscribeProgress) return;
const api = window.electronAPI?.localTransfer;
if (!api) return;
unsubscribeProgress = api.onProgress((payload: LocalTransferProgress) => {
const listener = progressListeners.get(payload.transferId);
listener?.({ transferred: payload.transferred, total: payload.total });
});
}
function requireTransferApi() {
const api = window.electronAPI?.localTransfer;
if (!api) {
throw new Error("Local transfers are only available in the desktop app");
}
return api;
}
function transferOriginFor(sessionId: string): LocalTransferOrigin {
return getSessionOrigin(sessionId);
}
/**
* The token the desktop renderer uses for the embedded backend. The `jwt`
* cookie the main process would otherwise rely on lasts only a day after an
* interactive login, while this token (and every request the app makes with
* it) keeps working, so transfers must carry it too. Remote-origin transfers
* are authenticated by the main process from its own Remote Sync state.
*/
function localAuthTokenFor(origin: LocalTransferOrigin): string | undefined {
if (origin !== "local") return undefined;
return getLocalAuthToken() ?? undefined;
}
export function createLocalTransferId(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* Streams a file from the local disk into `remoteDir` on the SSH session,
* using the same multipart route the in-browser upload uses.
*/
export async function uploadLocalFileToSession(options: {
sessionId: string;
remoteDir: string;
localPath: string;
fileName: string;
hostId?: number;
transferId?: string;
onProgress?: ProgressListener;
}): Promise<void> {
const api = requireTransferApi();
ensureProgressSubscription();
const transferId =
options.transferId ?? createLocalTransferId("local-upload");
const fields: Record<string, string> = {
sessionId: options.sessionId,
path: options.remoteDir,
};
if (options.hostId !== undefined) fields.hostId = String(options.hostId);
if (options.onProgress) {
progressListeners.set(transferId, options.onProgress);
}
const origin = transferOriginFor(options.sessionId);
try {
const result = await api.upload({
transferId,
origin,
deviceId: getDeviceId() ?? undefined,
authToken: localAuthTokenFor(origin),
fields,
localPath: options.localPath,
fileName: options.fileName,
});
if (result.success !== true) {
throw new LocalTransferError(
result.error || "Upload failed",
result.code,
);
}
} finally {
progressListeners.delete(transferId);
}
}
/**
* Streams a remote file from the SSH session straight to `destPath` on the
* local disk. Refuses to replace an existing file (code "EEXIST") unless
* `overwrite` is set.
*/
export async function downloadSessionFileToLocal(options: {
sessionId: string;
remotePath: string;
destPath: string;
/** The folder the user picked; the main process refuses any destination outside it. */
rootPath: string;
expectedSize?: number;
overwrite?: boolean;
transferId?: string;
onProgress?: ProgressListener;
}): Promise<void> {
const api = requireTransferApi();
ensureProgressSubscription();
const transferId =
options.transferId ?? createLocalTransferId("local-download");
if (options.onProgress) {
progressListeners.set(transferId, options.onProgress);
}
const origin = transferOriginFor(options.sessionId);
try {
const result = await api.download({
transferId,
origin,
deviceId: getDeviceId() ?? undefined,
authToken: localAuthTokenFor(origin),
body: { sessionId: options.sessionId, path: options.remotePath },
destPath: options.destPath,
rootPath: options.rootPath,
expectedSize: options.expectedSize,
overwrite: options.overwrite === true,
});
if (result.success !== true) {
throw new LocalTransferError(
result.error || "Download failed",
result.code,
);
}
} finally {
progressListeners.delete(transferId);
}
}
export async function cancelLocalTransfer(transferId: string): Promise<void> {
const api = window.electronAPI?.localTransfer;
if (!api) return;
await api.cancel(transferId);
}
@@ -0,0 +1,995 @@
import { getErrorMessage } from "../lib/error-message";
import axios from "axios";
import { asHttpError } from "../lib/http-error";
import {
handleApiError,
getFileManagerApiForSession,
setSessionOrigin,
clearSessionOrigin,
} from "./client";
import {
createFrontendLogger,
resolveConnectionOrigin,
} from "@termix/plugin-sdk/ui";
import type { FileItem, SSHHost } from "../host-types";
import { getCachedFileList } from "../lib/file-list-request-cache";
import {
getCachedFileContent,
invalidateCachedFileContent,
type FileContentResult,
} from "../lib/file-content-request-cache";
const fileLogger = createFrontendLogger("FILE");
type ApiConnectionLog = {
type: "info" | "success" | "warning" | "error";
stage: string;
message: string;
details?: Record<string, unknown>;
};
type ConnectErrorResponse = {
error?: string;
message?: string;
connectionLogs?: ApiConnectionLog[];
requires_totp?: boolean;
requires_browser_sign_in?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
code?: string;
label?: string;
status?: string;
reason?: string;
};
/**
* The interactive-auth branches /connect can take before a session exists.
* Callers switch on these, so they cannot stay behind Record<string, unknown>.
*/
export interface SSHConnectResult {
success?: boolean;
status?: string;
reason?: "timeout" | "no_keyboard" | "auth_failed";
requires_totp?: boolean;
requires_browser_sign_in?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
code?: string;
label?: string;
}
function triggerBlobDownload(blob: Blob, fileName: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.style.display = "none";
document.body.appendChild(link);
link.click();
window.setTimeout(() => {
link.remove();
URL.revokeObjectURL(url);
}, 1000);
}
// SSH FILE OPERATIONS
// ============================================================================
export async function connectSSH(
sessionId: string,
config: {
hostId?: number;
/** Names the host across a sync pair; hostId only names it locally. */
syncId?: string | null;
ip: string;
port: number;
username: string;
password?: string;
sshKey?: string;
keyPassword?: string;
authType?: string;
credentialId?: number;
userId?: string;
forceKeyboardInteractive?: boolean;
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown;
jumpHosts?: Array<{ hostId: number }>;
},
): Promise<SSHConnectResult> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/connect",
{ sessionId, ...config },
{ timeout: 120000 },
);
return response.data;
} catch (error: unknown) {
if (
axios.isAxiosError<ConnectErrorResponse>(error) &&
error.response?.data?.connectionLogs
) {
const data = error.response.data;
const errorWithLogs = new Error(
data.error || data.message || error.message,
);
Object.assign(errorWithLogs, {
connectionLogs: data.connectionLogs,
});
if (data.requires_totp) {
Object.assign(errorWithLogs, {
requires_totp: true,
sessionId: data.sessionId,
prompt: data.prompt,
});
}
if (data.requires_browser_sign_in) {
Object.assign(errorWithLogs, {
requires_browser_sign_in: true,
sessionId: data.sessionId,
url: data.url,
code: data.code,
label: data.label,
});
}
if (data.status === "auth_required") {
Object.assign(errorWithLogs, {
status: "auth_required",
reason: data.reason,
});
}
throw errorWithLogs;
}
handleApiError(error, "connect SSH");
}
}
export async function disconnectSSH(
sessionId: string,
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/disconnect",
{ sessionId },
);
return response.data;
} catch (error) {
handleApiError(error, "disconnect SSH");
} finally {
clearSessionOrigin(sessionId);
}
}
export async function verifySSHTOTP(
sessionId: string,
totpCode: string,
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/connect-totp",
{ sessionId, totpCode },
);
return response.data;
} catch (error) {
handleApiError(error, "verify SSH TOTP");
}
}
export async function verifySSHBrowserSignIn(
sessionId: string,
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/connect-browser-sign-in",
{ sessionId },
);
return response.data;
} catch (error) {
handleApiError(error, "verify SSH browser sign-in");
}
}
export async function getSSHStatus(
sessionId: string,
): Promise<{ connected: boolean }> {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/status",
{ params: { sessionId } },
);
return response.data;
} catch (error) {
handleApiError(error, "get SSH status");
}
}
export async function keepSSHAlive(
sessionId: string,
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/keepalive",
{ sessionId },
);
return response.data;
} catch (error) {
handleApiError(error, "SSH keepalive");
}
}
export async function listSSHFiles(
sessionId: string,
path: string,
options: { force?: boolean } = {},
): Promise<{ files: FileItem[]; path: string }> {
return getCachedFileList(
sessionId,
path,
async () => {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/listFiles",
{ params: { sessionId, path } },
);
return response.data || { files: [], path };
} catch (error) {
handleApiError(error, "list SSH files");
}
},
options.force,
);
}
export async function identifySSHSymlink(
sessionId: string,
path: string,
): Promise<{ path: string; target: string; type: "directory" | "file" }> {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/identifySymlink",
{ params: { sessionId, path } },
);
return response.data;
} catch (error) {
handleApiError(error, "identify SSH symlink");
}
}
export async function resolveSSHPath(
sessionId: string,
path: string,
): Promise<string> {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/resolvePath",
{ params: { sessionId, path } },
);
return response.data?.resolvedPath || path;
} catch {
return path;
}
}
export async function readSSHFile(
sessionId: string,
path: string,
options: { force?: boolean } = {},
): Promise<FileContentResult> {
return getCachedFileContent(
sessionId,
path,
async () => {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/readFile",
{ params: { sessionId, path } },
);
return response.data;
} catch (error: unknown) {
const httpError = asHttpError(error);
if (httpError.response?.status === 404) {
const customError = new Error("File not found");
(
customError as Error & {
response?: unknown;
isFileNotFound?: boolean;
}
).response = httpError.response;
(
customError as Error & {
response?: unknown;
isFileNotFound?: boolean;
}
).isFileNotFound = httpError.response.data?.fileNotFound || true;
throw customError;
}
handleApiError(error, "read SSH file");
}
},
options.force,
);
}
export async function writeSSHFile(
sessionId: string,
path: string,
content: string,
hostId?: number,
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/writeFile",
{ sessionId, path, content, hostId, userId },
);
if (
response.data &&
(response.data.message === "File written successfully" ||
response.status === 200)
) {
invalidateCachedFileContent(sessionId, path);
return response.data;
} else {
throw new Error("File write operation did not return success status");
}
} catch (error) {
handleApiError(error, "write SSH file");
}
}
export async function uploadSSHFile(
sessionId: string,
path: string,
fileName: string,
file: File,
hostId?: number,
userId?: string,
onChunkProgress?: (progress: {
chunkIndex: number;
totalChunks: number;
bytesSent: number;
totalBytes: number;
}) => void,
signal?: AbortSignal,
): Promise<Record<string, unknown>> {
// Browser-side safety: any single multipart body approaching 2^31 bytes (~2.14GB)
// crashes the XHR/ArrayBuffer pipeline in both Chromium (Electron) and Firefox,
// surfacing as "DOMException: File could not be read" inside FileManager-*.js.
// For larger files we slice into <=8MB chunks and upload each one separately,
// never materializing the whole file in JS memory.
const CHUNK_THRESHOLD_BYTES = 1.5 * 1024 * 1024 * 1024; // 1.5 GiB
const CHUNK_SIZE_BYTES = 8 * 1024 * 1024; // 8 MiB
try {
signal?.throwIfAborted();
if (file.size > CHUNK_THRESHOLD_BYTES) {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE_BYTES);
fileLogger.info("Starting chunked upload", {
operation: "file_upload_chunked_start",
fileName,
fileSize: file.size,
totalChunks,
chunkSize: CHUNK_SIZE_BYTES,
});
let bytesSent = 0;
for (let i = 0; i < totalChunks; i++) {
signal?.throwIfAborted();
const start = i * CHUNK_SIZE_BYTES;
const end = Math.min(start + CHUNK_SIZE_BYTES, file.size);
const chunkBlob = file.slice(start, end);
const response = await getFileManagerApiForSession(sessionId).post(
"/uploadFileChunk",
chunkBlob,
{
params: {
sessionId,
path,
fileName,
offset: start,
totalSize: file.size,
},
headers: { "Content-Type": "application/octet-stream" },
timeout: 0,
signal,
},
);
bytesSent = end;
onChunkProgress?.({
chunkIndex: i,
totalChunks,
bytesSent,
totalBytes: file.size,
});
if (i === totalChunks - 1) {
fileLogger.success("Chunked upload completed", {
operation: "file_upload_chunked_complete",
fileName,
fileSize: file.size,
totalChunks,
});
return response.data;
}
}
return { message: "File uploaded successfully", chunked: true };
}
const form = new FormData();
form.append("sessionId", sessionId);
form.append("path", path);
if (hostId !== undefined) form.append("hostId", String(hostId));
if (userId !== undefined) form.append("userId", userId);
form.append("file", file, fileName);
const response = await getFileManagerApiForSession(sessionId).postForm(
"/uploadFileStream",
form,
{
timeout: 0,
signal,
onUploadProgress: (event) => {
const totalBytes =
typeof event.total === "number" && event.total > 0
? event.total
: file.size;
onChunkProgress?.({
chunkIndex: 0,
totalChunks: 1,
bytesSent: Math.min(event.loaded, totalBytes),
totalBytes,
});
},
},
);
return response.data;
} catch (error) {
if (signal?.aborted) throw error;
handleApiError(error, "upload SSH file");
}
}
export interface DownloadedSSHFile {
/** base64-encoded file contents */
content: string;
fileName: string;
size: number;
mimeType: string;
path: string;
}
export async function downloadSSHFile(
sessionId: string,
filePath: string,
hostId?: number,
userId?: string,
): Promise<DownloadedSSHFile> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/downloadFile",
{
sessionId,
path: filePath,
hostId,
userId,
},
{ timeout: 0 },
);
return response.data;
} catch (error) {
handleApiError(error, "download SSH file");
}
}
export interface DownloadProgressEvent {
loaded: number;
total?: number;
}
export async function downloadSSHFileStream(
sessionId: string,
filePath: string,
onProgress?: (event: DownloadProgressEvent) => void,
): Promise<void> {
const response = await getFileManagerApiForSession(sessionId).post(
"/downloadFileStream",
{ sessionId, path: filePath },
{
responseType: "blob",
timeout: 0,
onDownloadProgress: onProgress
? (event) => onProgress({ loaded: event.loaded, total: event.total })
: undefined,
},
);
const blob = response.data as Blob;
const fileName = filePath.split("/").pop() || "download";
triggerBlobDownload(blob, fileName);
}
export async function createSSHFile(
sessionId: string,
path: string,
fileName: string,
content: string = "",
hostId?: number,
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/createFile",
{ sessionId, path, fileName, content, hostId, userId },
);
return response.data;
} catch (error) {
handleApiError(error, "create SSH file");
}
}
export async function createSSHFolder(
sessionId: string,
path: string,
folderName: string,
hostId?: number,
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/createFolder",
{ sessionId, path, folderName, hostId, userId },
);
return response.data;
} catch (error) {
handleApiError(error, "create SSH folder");
}
}
export async function deleteSSHItem(
sessionId: string,
path: string,
isDirectory: boolean,
hostId?: number,
userId?: string,
permanent = false,
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).delete(
"/deleteItem",
{
data: {
sessionId,
path,
isDirectory,
hostId,
userId,
permanent,
},
},
);
if (!isDirectory) invalidateCachedFileContent(sessionId, path);
return response.data;
} catch (error) {
if (
axios.isAxiosError(error) &&
error.response?.data?.trashUnavailable === true
) {
throw error;
}
handleApiError(error, "delete SSH item");
}
}
export interface TrashItem {
id: string;
name: string;
originalPath: string;
isDirectory: boolean;
deletedAt: string;
size: number;
}
export async function getSSHTrash(sessionId: string): Promise<{
items: TrashItem[];
retentionDays: number;
canManageRetention: boolean;
}> {
try {
const response = await getFileManagerApiForSession(sessionId).get(
"/trash",
{ params: { sessionId } },
);
return response.data;
} catch (error) {
handleApiError(error, "list SSH trash");
}
}
export async function restoreSSHTrashItem(sessionId: string, id: string) {
try {
const response = await getFileManagerApiForSession(sessionId).post(
`/trash/${encodeURIComponent(id)}/restore`,
{ sessionId },
);
return response.data;
} catch (error) {
handleApiError(error, "restore SSH trash item");
}
}
export async function permanentlyDeleteSSHTrashItem(
sessionId: string,
id: string,
) {
try {
await getFileManagerApiForSession(sessionId).delete(
`/trash/${encodeURIComponent(id)}`,
{ data: { sessionId } },
);
} catch (error) {
handleApiError(error, "permanently delete SSH trash item");
}
}
export async function emptySSHTrash(sessionId: string) {
try {
await getFileManagerApiForSession(sessionId).delete("/trash", {
data: { sessionId },
});
} catch (error) {
handleApiError(error, "empty SSH trash");
}
}
export async function updateSSHTrashRetention(
sessionId: string,
retentionDays: number,
) {
try {
await getFileManagerApiForSession(sessionId).put("/trash-retention", {
retentionDays,
});
} catch (error) {
handleApiError(error, "update SSH trash retention");
}
}
export async function setSudoPassword(
sessionId: string,
password: string,
): Promise<void> {
try {
await getFileManagerApiForSession(sessionId).post("/sudo-password", {
sessionId,
password,
});
} catch (error) {
handleApiError(error, "set sudo password");
}
}
export interface CopySSHItemResult {
message?: string;
/** Set when the copy was renamed to avoid clobbering an existing entry. */
uniqueName?: string;
targetPath?: string;
}
export async function copySSHItem(
sessionId: string,
sourcePath: string,
targetDir: string,
hostId?: number,
userId?: string,
): Promise<CopySSHItemResult> {
try {
const response = await getFileManagerApiForSession(sessionId).post(
"/copyItem",
{
sessionId,
sourcePath,
targetDir,
hostId,
userId,
},
{
timeout: 60000,
},
);
return response.data;
} catch (error) {
handleApiError(error, "copy SSH item");
throw error;
}
}
export async function renameSSHItem(
sessionId: string,
oldPath: string,
newName: string,
hostId?: number,
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).put(
"/renameItem",
{ sessionId, oldPath, newName, hostId, userId },
);
invalidateCachedFileContent(sessionId, oldPath);
const separator = oldPath.lastIndexOf("/");
const newPath = `${oldPath.slice(0, separator + 1)}${newName}`;
invalidateCachedFileContent(sessionId, newPath);
return response.data;
} catch (error) {
handleApiError(error, "rename SSH item");
throw error;
}
}
export async function moveSSHItem(
sessionId: string,
oldPath: string,
newPath: string,
hostId?: number,
userId?: string,
): Promise<Record<string, unknown>> {
try {
const response = await getFileManagerApiForSession(sessionId).put(
"/moveItem",
{
sessionId,
oldPath,
newPath,
hostId,
userId,
},
{
timeout: 60000,
},
);
invalidateCachedFileContent(sessionId, oldPath);
invalidateCachedFileContent(sessionId, newPath);
return response.data;
} catch (error) {
handleApiError(error, "move SSH item");
throw error;
}
}
export async function changeSSHPermissions(
sessionId: string,
path: string,
permissions: string,
hostId?: number,
userId?: string,
): Promise<{ success: boolean; message: string }> {
try {
fileLogger.info("Changing SSH file permissions", {
operation: "change_permissions",
sessionId,
path,
permissions,
hostId,
userId,
});
const response = await getFileManagerApiForSession(sessionId).post(
"/changePermissions",
{ sessionId, path, permissions, hostId, userId },
);
fileLogger.success("SSH file permissions changed successfully", {
operation: "change_permissions",
sessionId,
path,
permissions,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to change SSH file permissions", error, {
operation: "change_permissions",
sessionId,
path,
permissions,
});
handleApiError(error, "change SSH permissions");
throw error;
}
}
export async function extractSSHArchive(
sessionId: string,
archivePath: string,
extractPath?: string,
hostId?: number,
userId?: string,
): Promise<{ success: boolean; message: string; extractPath: string }> {
try {
fileLogger.info("Extracting archive", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath,
hostId,
userId,
});
const response = await getFileManagerApiForSession(sessionId).post(
"/extractArchive",
{ sessionId, archivePath, extractPath, hostId, userId },
);
fileLogger.success("Archive extracted successfully", {
operation: "extract_archive",
sessionId,
archivePath,
extractPath: response.data.extractPath,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to extract archive", error, {
operation: "extract_archive",
sessionId,
archivePath,
extractPath,
});
handleApiError(error, "extract archive");
throw error;
}
}
export async function compressSSHFiles(
sessionId: string,
paths: string[],
archiveName: string,
format?: string,
hostId?: number,
userId?: string,
): Promise<{ success: boolean; message: string; archivePath: string }> {
try {
fileLogger.info("Compressing files", {
operation: "compress_files",
sessionId,
paths,
archiveName,
format,
hostId,
userId,
});
const response = await getFileManagerApiForSession(sessionId).post(
"/compressFiles",
{
sessionId,
paths,
archiveName,
format: format || "zip",
hostId,
userId,
},
);
fileLogger.success("Files compressed successfully", {
operation: "compress_files",
sessionId,
paths,
archivePath: response.data.archivePath,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to compress files", error, {
operation: "compress_files",
sessionId,
paths,
archiveName,
format,
});
handleApiError(error, "compress files");
throw error;
}
}
// ============================================================================
export type HostConnectionState =
"disconnected" | "connecting" | "ready" | "auth_required" | "error";
export interface EnsureSSHSessionResult {
state: HostConnectionState;
sessionId?: string;
error?: string;
}
export async function ensureSSHSessionForHost(
host: SSHHost,
): Promise<EnsureSSHSessionResult> {
const sessionId = host.id.toString();
const origin = await resolveConnectionOrigin({
connectionOrigin: host.connectionOrigin,
});
setSessionOrigin(sessionId, origin);
try {
const status = await getSSHStatus(sessionId);
if (status?.connected) {
return { state: "ready", sessionId };
}
} catch {
// not connected — fall through to connect
}
try {
const result = await connectSSH(sessionId, {
hostId: host.id,
syncId: host.syncId ?? null,
ip: host.ip,
port: host.port,
username: host.username,
password: host.password,
sshKey: host.key,
keyPassword: host.keyPassword,
authType: host.authType,
credentialId: host.credentialId,
userId: host.userId,
forceKeyboardInteractive: host.forceKeyboardInteractive,
jumpHosts: host.jumpHosts,
useSocks5: host.useSocks5,
socks5Host: host.socks5Host,
socks5Port: host.socks5Port,
socks5Username: host.socks5Username,
socks5Password: host.socks5Password,
socks5ProxyChain: host.socks5ProxyChain,
});
if (
result?.requires_totp ||
result?.requires_browser_sign_in ||
result?.status === "auth_required"
) {
return { state: "auth_required", sessionId };
}
return { state: "ready", sessionId };
} catch (err) {
const message = getErrorMessage(err, "Connection failed");
return { state: "error", error: message };
}
}
export interface BrowseSSHDirectoryResult {
status: "ok" | "not_found" | "error";
path: string;
files: FileItem[];
}
export async function browseSSHDirectory(
sessionId: string,
path: string,
): Promise<BrowseSSHDirectoryResult> {
try {
const result = await listSSHFiles(sessionId, path);
return {
status: "ok",
path: result.path,
files: result.files,
};
} catch (err) {
const status =
(err as { response?: { status?: number } })?.response?.status === 404
? "not_found"
: "error";
return { status, path, files: [] };
}
}
@@ -0,0 +1,384 @@
import { createFrontendLogger } from "@termix/plugin-sdk/ui";
import { fileManagerApi, handleApiError } from "./client";
const fileLogger = createFrontendLogger("FILE");
// ============================================================================
// HOST-TO-HOST TRANSFER
// ============================================================================
export type TransferMethodPreference = "auto" | "tar" | "item_sftp";
export interface TransferScanSummary {
fileCount: number;
totalBytes: number;
largestFileBytes: number;
incompressibleRatio: number;
}
export interface TransferMethodPreview {
methodPreference: TransferMethodPreference;
resolvedMethod: "tar" | "item_sftp";
reasonKey: string;
sourcePlatform: "unix" | "windows";
destPlatform: "unix" | "windows";
sourceHasTar: boolean;
destHasTar: boolean;
summary: TransferScanSummary;
}
export async function getTransferMethodPreview(
sourceSessionId: string,
sourcePaths: string[],
destSessionId: string,
destPath: string,
methodPreference?: TransferMethodPreference,
): Promise<TransferMethodPreview> {
try {
const response = await fileManagerApi().post("/transferMethodPreview", {
sourceSessionId,
sourcePaths,
destSessionId,
destPath,
methodPreference: methodPreference ?? "auto",
});
return response.data;
} catch (error) {
handleApiError(error, "preview transfer method");
throw error;
}
}
export interface TransferHopMetrics {
id: string;
mbPerSec?: number;
}
export interface TransferTimings {
prepareDestMs?: number;
compressMs?: number;
transferMs?: number;
extractMs?: number;
verifyMs?: number;
directBenchmarkMs?: number;
relayBenchmarkMs?: number;
sourceDeleteMs?: number;
totalMs?: number;
transferBytes?: number;
endToEndMbPerSec?: number;
hops?: TransferHopMetrics[];
}
export function getTransferProgressPercent(
status: TransferProgressResponse,
): number | undefined {
if (
status.bytesTransferred !== undefined &&
status.totalBytes !== undefined &&
status.totalBytes > 0
) {
return Math.min(
100,
Math.round((status.bytesTransferred / status.totalBytes) * 100),
);
}
if (
status.itemsCompleted !== undefined &&
status.totalItems !== undefined &&
status.totalItems > 0
) {
return Math.min(
100,
Math.round((status.itemsCompleted / status.totalItems) * 100),
);
}
return undefined;
}
export function formatTransferMbPerSec(
mbPerSec?: number,
_bytes?: number,
_ms?: number,
): string {
if (mbPerSec === undefined || mbPerSec <= 0) return "";
if (mbPerSec < 1) return `${(mbPerSec * 1024).toFixed(0)} KB/s`;
return `${mbPerSec.toFixed(1)} MB/s`;
}
export function formatDurationMs(ms?: number): string {
if (ms === undefined) return "";
if (ms < 1000) return `${ms}ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60000);
const seconds = Math.round((ms % 60000) / 1000);
return `${minutes}m ${seconds}s`;
}
interface TransferProgressTracker {
update(status: TransferProgressResponse): {
rate: number | undefined;
stalled: boolean;
};
}
export function createTransferProgressTracker(): TransferProgressTracker {
let lastBytes: number | undefined;
let lastTime: number | undefined;
let lastRate: number | undefined;
let lastActivityTime: number | undefined;
const STALL_THRESHOLD_MS = 5000;
return {
update(status) {
const now = Date.now();
const bytes = status.bytesTransferred;
if (
bytes !== undefined &&
lastBytes !== undefined &&
lastTime !== undefined
) {
const deltaBytes = bytes - lastBytes;
const deltaMs = now - lastTime;
if (deltaMs > 0 && deltaBytes >= 0) {
lastRate = (deltaBytes / deltaMs / 1024 / 1024) * 1000;
if (deltaBytes > 0) lastActivityTime = now;
}
} else if (bytes !== undefined) {
lastActivityTime = now;
}
lastBytes = bytes;
lastTime = now;
const stalled =
lastActivityTime !== undefined &&
now - lastActivityTime > STALL_THRESHOLD_MS &&
status.status === "running" &&
status.phase === "transferring";
return { rate: lastRate, stalled };
},
};
}
export interface TransferProgressResponse {
transferId: string;
status: "running" | "success" | "partial" | "error" | "cancelled";
phase:
| "compressing"
| "transferring"
| "benchmarking"
| "verifying"
| "extracting"
| "reconnecting";
bytesTransferred?: number;
totalBytes?: number;
itemsCompleted?: number;
totalItems?: number;
failedPaths?: string[];
message?: string;
method?: "stream" | "tar" | "item_sftp" | "direct_rsync";
sourcePaths?: string[];
destPath?: string;
sourceSessionId?: string;
destSessionId?: string;
startedAt?: number;
timings?: TransferTimings;
sourceDeleted?: boolean;
moveRequested?: boolean;
partialDestRemaining?: boolean;
cleanupCompleted?: boolean;
retryable?: boolean;
integrityVerified?: boolean;
parallelSegmentCount?: number;
}
export async function transferToHost(
sourceSessionId: string,
sourcePaths: string[],
destSessionId: string,
destPath: string,
move?: boolean,
methodPreference?: TransferMethodPreference,
parallelSegmentCount?: number,
): Promise<{ transferId: string }> {
try {
fileLogger.info("Starting host transfer", {
operation: "host_transfer",
sourceSessionId,
destSessionId,
sourcePaths,
destPath,
move,
methodPreference,
parallelSegmentCount,
});
const response = await fileManagerApi().post("/transferToHost", {
sourceSessionId,
sourcePaths,
destSessionId,
destPath,
move,
methodPreference: methodPreference ?? "auto",
parallelSegmentCount,
});
return response.data;
} catch (error) {
fileLogger.error("Failed to start host transfer", error, {
operation: "host_transfer",
sourceSessionId,
destSessionId,
sourcePaths,
});
handleApiError(error, "transfer to host");
throw error;
}
}
export async function getTransferStatus(
transferId: string,
): Promise<TransferProgressResponse> {
try {
const response = await fileManagerApi().get(
`/transferStatus/${transferId}`,
);
return response.data;
} catch (error) {
handleApiError(error, "get transfer status");
throw error;
}
}
export async function listActiveTransfers(): Promise<{
transfers: TransferProgressResponse[];
}> {
try {
const response = await fileManagerApi().get("/activeTransfers");
return response.data;
} catch (error) {
handleApiError(error, "list active transfers");
throw error;
}
}
export async function cancelTransferToHost(transferId: string): Promise<void> {
try {
await fileManagerApi().post(`/transferCancel/${transferId}`);
} catch (error) {
fileLogger.warn("Transfer cancel request failed (non-fatal)", {
operation: "host_transfer",
transferId,
error,
});
}
}
export async function cleanupCancelledTransfer(
transferId: string,
): Promise<{ removedPaths: string[]; failedPaths: string[] }> {
try {
const response = await fileManagerApi().post(
`/transferCleanup/${transferId}`,
);
return response.data;
} catch (error) {
handleApiError(error, "clean up cancelled transfer");
throw error;
}
}
export async function retryTransferToHost(
transferId: string,
): Promise<{ ok: boolean; transferId: string }> {
try {
const response = await fileManagerApi().post(
`/transferRetry/${transferId}`,
);
return response.data;
} catch (error) {
handleApiError(error, "retry transfer");
throw error;
}
}
export async function pollTransferUntilComplete(
transferId: string,
onProgress?: (status: TransferProgressResponse) => void,
intervalMs = 500,
): Promise<TransferProgressResponse> {
return new Promise((resolve, reject) => {
const poll = async () => {
try {
const status = await getTransferStatus(transferId);
onProgress?.(status);
if (
status.status === "success" ||
status.status === "partial" ||
status.status === "error" ||
status.status === "cancelled"
) {
resolve(status);
return;
}
setTimeout(poll, intervalMs);
} catch (err) {
reject(err);
}
};
void poll();
});
}
// ============================================================================
// FILE MANAGER DATA
// ============================================================================
export interface TransferDestination {
id: number;
userId: string;
sourceHostId: number;
destHostId: number;
destPath: string;
destPathLabel?: string;
lastUsed?: string;
}
export async function getTransferRecent(
sourceHostId: number,
): Promise<TransferDestination[]> {
try {
const response = await fileManagerApi().get("/transfer-recent", {
params: { sourceHostId },
});
return response.data;
} catch (error) {
handleApiError(error, "get transfer recent destinations");
throw error;
}
}
export async function addTransferRecent(
sourceHostId: number,
destHostId: number,
destPath: string,
destPathLabel?: string,
): Promise<Record<string, unknown>> {
try {
const response = await fileManagerApi().post("/transfer-recent", {
sourceHostId,
destHostId,
destPath,
destPathLabel,
});
return response.data;
} catch (error) {
handleApiError(error, "add transfer recent destination");
throw error;
}
}
@@ -0,0 +1,109 @@
import React from "react";
import AudioPlayer from "react-h5-audio-player";
import "react-h5-audio-player/lib/styles.css";
import { Music } from "lucide-react";
import { cn } from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
interface FileItem {
name: string;
size?: number;
}
interface AudioPreviewProps {
file: FileItem;
content: string;
color: string;
onMediaDimensionsChange?: (dimensions: {
width: number;
height: number;
}) => void;
}
function formatFileSize(bytes?: number, t?: (key: string) => string): string {
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
}
function getAudioMimeType(filename: string): string {
const ext = filename.split(".").pop()?.toLowerCase() || "";
switch (ext) {
case "mp3":
return "audio/mpeg";
case "wav":
return "audio/wav";
case "flac":
return "audio/flac";
case "ogg":
return "audio/ogg";
case "aac":
return "audio/aac";
case "m4a":
return "audio/mp4";
default:
return "audio/mpeg";
}
}
export function AudioPreview({
file,
content,
color,
onMediaDimensionsChange,
}: AudioPreviewProps) {
const { t } = useTranslation();
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const audioUrl = `data:${getAudioMimeType(file.name)};base64,${content}`;
return (
<div className="p-6 flex items-center justify-center h-full">
<div className="w-full max-w-2xl">
<div className="space-y-4">
<div className="flex justify-center">
<div
className={cn(
"w-32 h-32 rounded-lg bg-gradient-to-br from-pink-100 to-purple-100 flex items-center justify-center shadow-lg",
color,
)}
>
<Music className="w-16 h-16 text-pink-600" />
</div>
</div>
<div className="text-center">
<h3 className="font-semibold text-foreground text-lg mb-1">
{file.name.replace(/\.[^/.]+$/, "")}
</h3>
<p className="flex justify-center gap-3 text-sm text-muted-foreground">
<span>{ext.toUpperCase()}</span>
<span>{formatFileSize(file.size, t)}</span>
</p>
</div>
<div className="rounded-lg overflow-hidden">
<AudioPlayer
src={audioUrl}
onLoadedMetaData={() => {
onMediaDimensionsChange?.({
width: 600,
height: 400,
});
}}
onError={(e) => {
console.error("Audio playback error:", e);
}}
showJumpControls={false}
showSkipControls={false}
showDownloadProgress={true}
customAdditionalControls={[]}
customVolumeControls={[]}
/>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,164 @@
import React, { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import {
loadLanguage,
type LanguageName,
} from "@uiw/codemirror-extensions-langs";
import { EditorView, keymap } from "@codemirror/view";
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
import {
defaultKeymap,
history,
historyKeymap,
toggleComment,
} from "@codemirror/commands";
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
export interface CodeEditorHandle {
openSearchPanel: () => void;
}
interface CodeEditorProps {
fileName: string;
value: string;
placeholder: string;
onChange: (value: string) => void;
onFocus: () => void;
onBlur: () => void;
fontSize?: number;
}
function getLanguageExtension(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase();
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
return loadLanguage(baseName as LanguageName);
}
const langMap: Record<string, string> = {
js: "javascript",
jsx: "jsx",
ts: "typescript",
tsx: "tsx",
py: "python",
java: "java",
cpp: "cpp",
c: "c",
cs: "csharp",
php: "php",
rb: "ruby",
go: "go",
rs: "rust",
html: "html",
css: "css",
scss: "sass",
less: "less",
json: "json",
xml: "xml",
yaml: "yaml",
yml: "yaml",
toml: "toml",
sql: "sql",
sh: "shell",
bash: "shell",
zsh: "shell",
vue: "vue",
svelte: "svelte",
md: "markdown",
conf: "shell",
ini: "properties",
};
const language = langMap[ext];
return language ? loadLanguage(language as LanguageName) : null;
}
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
function CodeEditor(
{ fileName, value, placeholder, onChange, onFocus, onBlur, fontSize = 14 },
ref,
) {
const editorRef = useRef<{ view?: EditorView } | null>(null);
const extensions = useMemo(() => {
const languageExtension = getLanguageExtension(fileName);
return [
...(languageExtension ? [languageExtension] : []),
history(),
search(),
autocompletion(),
keymap.of([
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...completionKeymap,
{
key: "Mod-/",
run: toggleComment,
preventDefault: true,
},
{
key: "Mod-h",
run: () => false,
preventDefault: true,
},
]),
EditorView.theme({
"&": {
height: "100%",
fontSize: `${fontSize}px`,
},
".cm-scroller": {
overflow: "auto",
scrollbarWidth: "thin",
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)",
},
".cm-editor": {
height: "100%",
},
}),
];
}, [fileName, fontSize]);
useImperativeHandle(
ref,
() => ({
openSearchPanel: () => {
const view = editorRef.current?.view;
if (view) {
openSearchPanel(view);
}
},
}),
[],
);
return (
<CodeMirror
ref={editorRef}
value={value}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
extensions={extensions}
theme={oneDark}
placeholder={placeholder}
className="h-full"
basicSetup={{
lineNumbers: true,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: false,
}}
/>
);
},
);
@@ -0,0 +1,43 @@
import type React from "react";
import { cn } from "@termix/plugin-sdk/ui";
interface ColumnResizeHandleProps {
onMouseDown: (event: React.MouseEvent) => void;
onDoubleClick: (event: React.MouseEvent) => void;
onClick: (event: React.MouseEvent) => void;
"data-resizing"?: string;
title?: string;
}
/**
* Thin grab area on the left edge of a list-view header cell. The parent cell
* must be `relative`. Shows a hairline on hover and while dragging.
*/
export function ColumnResizeHandle({
title,
"data-resizing": resizing,
...handlers
}: ColumnResizeHandleProps) {
return (
<div
role="separator"
aria-orientation="vertical"
title={title}
data-column-resize-handle
data-resizing={resizing}
className={cn(
"absolute -left-2 top-0 bottom-0 w-3 cursor-col-resize group/handle z-10",
)}
{...handlers}
>
<div
className={cn(
"absolute left-1/2 top-0.5 bottom-0.5 w-px -translate-x-1/2 transition-colors",
resizing
? "bg-accent-brand"
: "bg-transparent group-hover/handle:bg-accent-brand/60",
)}
/>
</div>
);
}
@@ -0,0 +1,105 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Check } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { cn } from "@termix/plugin-sdk/ui";
import type { ResizableColumns } from "../hooks/useResizableColumns.ts";
interface ColumnVisibilityMenuProps {
x: number;
y: number;
isVisible: boolean;
columns: ResizableColumns;
onClose: () => void;
}
/**
* Right-click menu for a list-view header: tick/untick which optional
* columns are shown. The name column is always on and not listed.
*/
export function ColumnVisibilityMenu({
x,
y,
isVisible,
columns,
onClose,
}: ColumnVisibilityMenuProps) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ x, y });
useEffect(() => {
if (!isVisible) return;
const handleMouseDown = (event: MouseEvent) => {
if (!menuRef.current?.contains(event.target as Node)) onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
const timeout = setTimeout(() => {
document.addEventListener("mousedown", handleMouseDown, true);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("blur", onClose);
}, 50);
return () => {
clearTimeout(timeout);
document.removeEventListener("mousedown", handleMouseDown, true);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("blur", onClose);
};
}, [isVisible, onClose]);
useLayoutEffect(() => {
if (!isVisible || !menuRef.current) return;
const { offsetWidth, offsetHeight } = menuRef.current;
setPosition({
x: Math.max(8, Math.min(x, window.innerWidth - offsetWidth - 10)),
y: Math.max(8, Math.min(y, window.innerHeight - offsetHeight - 10)),
});
}, [isVisible, x, y]);
if (!isVisible) return null;
const onlyOneVisible = columns.visibleColumns.length <= 1;
return (
<div
ref={menuRef}
data-context-menu
data-testid="column-visibility-menu"
className="fixed bg-card border border-border rounded-none shadow-md min-w-[200px] z-[99995] py-1"
style={{ left: position.x, top: position.y }}
>
<div className="px-3 pt-1.5 pb-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("fileManager.columns")}
</div>
{columns.columns.map((column) => {
const visible = columns.isVisible(column.key);
// Keep at least one optional column so the header stays meaningful.
const disabled = visible && onlyOneVisible;
return (
<button
key={column.key}
type="button"
role="menuitemcheckbox"
aria-checked={visible}
disabled={disabled}
className={cn(
"w-full px-3 min-h-8 py-1.5 text-left text-xs font-semibold flex items-center gap-2 rounded-none transition-colors cursor-pointer",
"hover:bg-accent-brand/10 hover:text-accent-brand",
disabled &&
"opacity-40 cursor-not-allowed hover:bg-transparent hover:text-current",
)}
onClick={() => {
if (!disabled) columns.toggleColumn(column.key);
}}
>
<span className="size-3.5 flex items-center justify-center text-accent-brand">
{visible && <Check className="size-3.5" />}
</span>
<span className="flex-1 leading-tight">{t(column.labelKey)}</span>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,170 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Button,
Input,
Label,
} from "@termix/plugin-sdk/ui";
import { Package } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
interface CompressDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
fileNames: string[];
onCompress: (archiveName: string, format: string) => void;
}
export function CompressDialog({
open,
onOpenChange,
fileNames,
onCompress,
}: CompressDialogProps) {
const { t } = useTranslation();
const [archiveName, setArchiveName] = useState("");
const [format, setFormat] = useState("zip");
useEffect(() => {
if (open && fileNames.length > 0) {
if (fileNames.length === 1) {
const baseName = fileNames[0].replace(/\.[^/.]+$/, "");
setArchiveName(baseName);
} else {
setArchiveName("archive");
}
}
}, [open, fileNames]);
const handleCompress = () => {
if (!archiveName.trim()) return;
let finalName = archiveName.trim();
const extensions: Record<string, string> = {
zip: ".zip",
"tar.gz": ".tar.gz",
"tar.bz2": ".tar.bz2",
"tar.xz": ".tar.xz",
tar: ".tar",
"7z": ".7z",
};
const expectedExtension = extensions[format];
if (expectedExtension && !finalName.endsWith(expectedExtension)) {
finalName += expectedExtension;
}
onCompress(finalName, format);
onOpenChange(false);
};
const formats = ["zip", "tar.gz", "tar.bz2", "tar.xz", "tar", "7z"] as const;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-md rounded-none border-border bg-card">
<DialogHeader>
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<Package className="size-4 text-accent-brand" />
{t("fileManager.compressFiles")}
</DialogTitle>
<DialogDescription className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">
{t("fileManager.compressFilesDesc", { count: fileNames.length })}
</DialogDescription>
</DialogHeader>
<div className="py-4 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label
className="text-xs font-bold uppercase tracking-widest text-muted-foreground"
htmlFor="archiveName"
>
{t("fileManager.archiveName")}
</Label>
<Input
id="archiveName"
value={archiveName}
onChange={(e) => setArchiveName(e.target.value)}
placeholder={t("fileManager.enterArchiveName")}
className="rounded-none bg-muted/50 border-border text-xs"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleCompress();
}
}}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("fileManager.compressionFormat")}
</Label>
<div className="grid grid-cols-3 gap-1">
{formats.map((f) => (
<button
key={f}
onClick={() => setFormat(f)}
className={`py-2 text-xs font-bold uppercase tracking-widest border transition-colors ${
format === f
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
.{f}
</button>
))}
</div>
</div>
<div className="rounded-none bg-muted/10 border border-border p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">
{t("fileManager.selectedFiles")}:
</p>
<ul className="text-xs space-y-1">
{fileNames.slice(0, 5).map((name, index) => (
<li
key={index}
className="truncate text-foreground font-medium"
>
{name}
</li>
))}
{fileNames.length > 5 && (
<li className="text-muted-foreground">
{t("fileManager.andMoreFiles", {
count: fileNames.length - 5,
})}
</li>
)}
</ul>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
onClick={handleCompress}
disabled={!archiveName.trim()}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
<Package className="size-3.5 mr-1" />
{t("fileManager.compress")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,346 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useState, useEffect } from "react";
import { DiffEditor } from "@monaco-editor/react";
import { Button } from "@termix/plugin-sdk/ui";
import { toast } from "sonner";
import { DownloadProgressToast } from "./DownloadProgressToast";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Download,
RefreshCw,
Eye,
EyeOff,
ArrowLeftRight,
FileText,
} from "lucide-react";
import {
readSSHFile,
downloadSSHFileStream,
getSSHStatus,
connectSSH,
} from "../api/ssh-file-operations-api";
import type { FileItem, SSHHost } from "../host-types";
interface DiffViewerProps {
file1: FileItem;
file2: FileItem;
sshSessionId: string;
sshHost: SSHHost;
onDownload1?: () => void;
onDownload2?: () => void;
}
export function DiffViewer({
file1,
file2,
sshSessionId,
sshHost,
}: DiffViewerProps) {
const { t } = useTranslation();
const [content1, setContent1] = useState<string>("");
const [content2, setContent2] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [diffMode, setDiffMode] = useState<"side-by-side" | "inline">(
"side-by-side",
);
const [showLineNumbers, setShowLineNumbers] = useState(true);
const ensureSSHConnection = async () => {
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
}
} catch {
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
}
};
const loadFileContents = async () => {
if (file1.type !== "file" || file2.type !== "file") {
setError(t("fileManager.canOnlyCompareFiles"));
return;
}
try {
setIsLoading(true);
setError(null);
await ensureSSHConnection();
const [response1, response2] = await Promise.all([
readSSHFile(sshSessionId, file1.path),
readSSHFile(sshSessionId, file2.path),
]);
setContent1(response1.content || "");
setContent2(response2.content || "");
} catch (error: unknown) {
console.error("Failed to load files for diff:", error);
const err = error as {
message?: string;
response?: { data?: { tooLarge?: boolean; error?: string } };
};
const errorData = err?.response?.data;
if (errorData?.tooLarge) {
setError(t("fileManager.fileTooLarge", { error: errorData.error }));
} else if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
setError(
t("fileManager.sshConnectionFailed", {
name: sshHost.name,
ip: sshHost.ip,
port: sshHost.port,
}),
);
} else {
setError(
t("fileManager.loadFileFailed", {
error:
err.message || errorData?.error || t("fileManager.unknownError"),
}),
);
}
} finally {
setIsLoading(false);
}
};
const handleDownloadFile = async (file: FileItem) => {
const toastId = toast.loading(
<DownloadProgressToast fileName={file.name} loaded={0} />,
{ duration: Infinity },
);
try {
await ensureSSHConnection();
await downloadSSHFileStream(
sshSessionId,
file.path,
({ loaded, total }) => {
toast.loading(
<DownloadProgressToast
fileName={file.name}
loaded={loaded}
total={total}
/>,
{ id: toastId, duration: Infinity },
);
},
);
toast.success(t("fileManager.downloadFileSuccess", { name: file.name }), {
id: toastId,
duration: undefined,
});
} catch (error: unknown) {
console.error("Failed to download file:", error);
const err = error as { message?: string };
toast.error(
t("fileManager.downloadFileFailed") +
": " +
(err.message || t("fileManager.unknownError")),
{ id: toastId, duration: undefined },
);
}
};
const getFileLanguage = (fileName: string): string => {
const ext = fileName.split(".").pop()?.toLowerCase();
const languageMap: Record<string, string> = {
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "typescript",
py: "python",
java: "java",
c: "c",
cpp: "cpp",
cs: "csharp",
php: "php",
rb: "ruby",
go: "go",
rs: "rust",
html: "html",
css: "css",
scss: "scss",
less: "less",
json: "json",
xml: "xml",
yaml: "yaml",
yml: "yaml",
md: "markdown",
sql: "sql",
sh: "shell",
bash: "shell",
ps1: "powershell",
dockerfile: "dockerfile",
};
return languageMap[ext || ""] || "plaintext";
};
useEffect(() => {
loadFileContents();
}, [file1, file2, sshSessionId]);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center bg-card">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">
{t("fileManager.loadingFileComparison")}
</p>
</div>
</div>
);
}
if (error) {
return (
<div className="h-full flex items-center justify-center bg-card">
<div className="text-center max-w-md">
<FileText className="w-16 h-16 mx-auto mb-4 text-red-500 opacity-50" />
<p className="text-red-500 mb-4">{error}</p>
<Button onClick={loadFileContents} variant="outline">
<RefreshCw className="w-4 h-4 mr-2" />
{t("fileManager.reload")}
</Button>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-card">
<div className="flex-shrink-0 border-b border-border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm">
<span className="text-muted-foreground">
{t("fileManager.compare")}:
</span>
<span className="font-medium text-green-400 mx-2">
{file1.name}
</span>
<ArrowLeftRight className="w-4 h-4 inline mx-1" />
<span className="font-medium text-blue-400">{file2.name}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
setDiffMode(
diffMode === "side-by-side" ? "inline" : "side-by-side",
)
}
>
{diffMode === "side-by-side"
? t("fileManager.sideBySide")
: t("fileManager.inline")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowLineNumbers(!showLineNumbers)}
>
{showLineNumbers ? (
<Eye className="w-4 h-4" />
) : (
<EyeOff className="w-4 h-4" />
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadFile(file1)}
title={t("fileManager.downloadFile", { name: file1.name })}
>
<Download className="w-4 h-4 mr-1" />
{file1.name}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadFile(file2)}
title={t("fileManager.downloadFile", { name: file2.name })}
>
<Download className="w-4 h-4 mr-1" />
{file2.name}
</Button>
<Button variant="outline" size="sm" onClick={loadFileContents}>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div className="flex-1">
<DiffEditor
original={content1}
modified={content2}
language={getFileLanguage(file1.name)}
theme="vs-dark"
options={{
renderSideBySide: diffMode === "side-by-side",
lineNumbers: showLineNumbers ? "on" : "off",
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 13,
wordWrap: "off",
automaticLayout: true,
readOnly: true,
originalEditable: false,
scrollbar: {
vertical: "visible",
horizontal: "visible",
},
diffWordWrap: "off",
ignoreTrimWhitespace: false,
}}
loading={
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">
{t("fileManager.initializingEditor")}
</p>
</div>
</div>
}
/>
</div>
</div>
);
}
@@ -0,0 +1,75 @@
import React from "react";
import { DraggableWindow } from "./DraggableWindow.tsx";
import { DiffViewer } from "./DiffViewer.tsx";
import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { FileItem, SSHHost } from "../host-types";
interface DiffWindowProps {
windowId: string;
file1: FileItem;
file2: FileItem;
sshSessionId: string;
sshHost: SSHHost;
initialX?: number;
initialY?: number;
}
export function DiffWindow({
windowId,
file1,
file2,
sshSessionId,
sshHost,
initialX = 150,
initialY = 100,
}: DiffWindowProps) {
const { t } = useTranslation();
const { closeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const currentWindow = windows.find((w) => w.id === windowId);
const handleClose = () => {
closeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
};
const handleFocus = () => {
focusWindow(windowId);
};
if (!currentWindow) {
return null;
}
return (
<DraggableWindow
title={t("fileManager.fileComparison", {
file1: file1.name,
file2: file2.name,
})}
initialX={initialX}
initialY={initialY}
initialWidth={1200}
initialHeight={700}
minWidth={800}
minHeight={500}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
>
<DiffViewer
file1={file1}
file2={file2}
sshSessionId={sshSessionId}
sshHost={sshHost}
/>
</DraggableWindow>
);
}
@@ -0,0 +1,71 @@
import { formatTransferMbPerSec } from "../api/transfer-api";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { ArrowDownToLine } from "lucide-react";
import { TransferProgressBar } from "./TransferProgressBar";
interface DownloadProgressToastProps {
fileName: string;
loaded: number;
total?: number;
mbPerSec?: number;
}
function formatBytes(bytes: number): string {
if (bytes <= 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
const formattedSize =
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
return `${formattedSize} ${units[unitIndex]}`;
}
export function DownloadProgressToast({
fileName,
loaded,
total,
mbPerSec,
}: DownloadProgressToastProps) {
const { t } = useTranslation();
const percent =
total !== undefined && total > 0
? Math.min(100, Math.round((loaded / total) * 100))
: undefined;
const speed = formatTransferMbPerSec(mbPerSec);
return (
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
<p className="flex items-center gap-2 text-sm font-medium leading-tight">
<ArrowDownToLine className="size-3.5 shrink-0 text-accent-brand" />
<span className="truncate">
{t("fileManager.downloadingFile", { name: fileName })}
</span>
</p>
<TransferProgressBar
value={percent}
label={t("fileManager.downloadingFile", { name: fileName })}
/>
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
<span className="min-w-0 truncate">
{total !== undefined
? t("fileManager.downloadProgressBytes", {
transferred: formatBytes(loaded),
total: formatBytes(total),
})
: formatBytes(loaded)}
</span>
<span
className={`shrink-0 tabular-nums ${speed ? "font-medium text-foreground" : "invisible"}`}
aria-hidden={!speed}
>
{speed || "0 MB/s"}
</span>
</div>
</div>
);
}
@@ -0,0 +1,381 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { cn } from "@termix/plugin-sdk/ui";
import { Minus, X, Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
interface DraggableWindowProps {
title: string;
children: React.ReactNode;
initialX?: number;
initialY?: number;
initialWidth?: number;
initialHeight?: number;
minWidth?: number;
minHeight?: number;
onClose: () => void;
onMinimize?: () => void;
onMaximize?: () => void;
onResize?: () => void;
isMaximized?: boolean;
zIndex?: number;
onFocus?: () => void;
targetSize?: { width: number; height: number };
titleActions?: React.ReactNode;
}
export function DraggableWindow({
title,
children,
initialX = 100,
initialY = 100,
initialWidth = 600,
initialHeight = 400,
minWidth = 300,
minHeight = 200,
onClose,
onMinimize,
onMaximize,
onResize,
isMaximized = false,
zIndex = 1000,
onFocus,
targetSize,
titleActions,
}: DraggableWindowProps) {
const { t } = useTranslation();
const [position, setPosition] = useState({ x: initialX, y: initialY });
const [size, setSize] = useState({
width: initialWidth,
height: initialHeight,
});
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState<string>("");
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [windowStart, setWindowStart] = useState({ x: 0, y: 0 });
const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 });
const containerBoundsRef = useRef({ width: 0, height: 0 });
const windowRef = useRef<HTMLDivElement>(null);
const titleBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (targetSize && !isMaximized) {
const container = windowRef.current?.offsetParent as HTMLElement | null;
const maxWidth = container
? Math.min(container.clientWidth * 0.9, 1200)
: Math.min(window.innerWidth * 0.9, 1200);
const maxHeight = container
? Math.min(container.clientHeight * 0.8, 800)
: Math.min(window.innerHeight * 0.8, 800);
let newWidth = Math.min(targetSize.width + 50, maxWidth);
let newHeight = Math.min(targetSize.height + 150, maxHeight);
if (newWidth > maxWidth || newHeight > maxHeight) {
const widthRatio = maxWidth / newWidth;
const heightRatio = maxHeight / newHeight;
const scale = Math.min(widthRatio, heightRatio);
newWidth = Math.floor(newWidth * scale);
newHeight = Math.floor(newHeight * scale);
}
newWidth = Math.max(newWidth, minWidth);
newHeight = Math.max(newHeight, minHeight);
setSize({ width: newWidth, height: newHeight });
setPosition({
x: Math.max(
0,
(container ? container.clientWidth : window.innerWidth) / 2 -
newWidth / 2,
),
y: Math.max(
0,
(container ? container.clientHeight : window.innerHeight) / 2 -
newHeight / 2,
),
});
}
}, [targetSize, isMaximized, minWidth, minHeight]);
const handleWindowClick = useCallback(() => {
onFocus?.();
}, [onFocus]);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (isMaximized) return;
e.preventDefault();
setIsDragging(true);
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y });
const container = windowRef.current?.offsetParent as HTMLElement | null;
containerBoundsRef.current = {
width: container ? container.clientWidth : window.innerWidth,
height: container ? container.clientHeight : window.innerHeight,
};
onFocus?.();
},
[isMaximized, position, onFocus],
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (isDragging && !isMaximized) {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
const newX = windowStart.x + deltaX;
const newY = windowStart.y + deltaY;
const { width: containerW, height: containerH } =
containerBoundsRef.current;
const maxX = containerW - size.width;
const maxY = containerH - size.height;
setPosition({
x: Math.max(0, Math.min(maxX, newX)),
y: Math.max(49, Math.min(maxY, newY)),
});
}
if (isResizing && !isMaximized) {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
let newWidth = sizeStart.width;
let newHeight = sizeStart.height;
let newX = windowStart.x;
let newY = windowStart.y;
if (resizeDirection.includes("right")) {
newWidth = Math.max(minWidth, sizeStart.width + deltaX);
}
if (resizeDirection.includes("left")) {
const widthChange = -deltaX;
newWidth = Math.max(minWidth, sizeStart.width + widthChange);
if (newWidth > minWidth || widthChange > 0) {
newX = windowStart.x - (newWidth - sizeStart.width);
} else {
newX = windowStart.x - (minWidth - sizeStart.width);
}
}
if (resizeDirection.includes("bottom")) {
newHeight = Math.max(minHeight, sizeStart.height + deltaY);
}
if (resizeDirection.includes("top")) {
const heightChange = -deltaY;
newHeight = Math.max(minHeight, sizeStart.height + heightChange);
if (newHeight > minHeight || heightChange > 0) {
newY = windowStart.y - (newHeight - sizeStart.height);
} else {
newY = windowStart.y - (minHeight - sizeStart.height);
}
}
const { width: containerW, height: containerH } =
containerBoundsRef.current;
newX = Math.max(0, Math.min(containerW - newWidth, newX));
newY = Math.max(49, Math.min(containerH - newHeight, newY));
setSize({ width: newWidth, height: newHeight });
setPosition({ x: newX, y: newY });
if (onResize) {
onResize();
}
}
},
[
isDragging,
isResizing,
isMaximized,
dragStart,
windowStart,
sizeStart,
size,
minWidth,
minHeight,
resizeDirection,
onResize,
],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setIsResizing(false);
setResizeDirection("");
}, []);
const handleResizeStart = useCallback(
(e: React.MouseEvent, direction: string) => {
if (isMaximized) return;
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
setResizeDirection(direction);
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y });
setSizeStart({ width: size.width, height: size.height });
const container = windowRef.current?.offsetParent as HTMLElement | null;
containerBoundsRef.current = {
width: container ? container.clientWidth : window.innerWidth,
height: container ? container.clientHeight : window.innerHeight,
};
onFocus?.();
},
[isMaximized, position, size, onFocus],
);
useEffect(() => {
if (isDragging || isResizing) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "none";
document.body.style.cursor = isDragging ? "grabbing" : "resizing";
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
};
}
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
const handleTitleDoubleClick = useCallback(() => {
onMaximize?.();
}, [onMaximize]);
return (
<div
ref={windowRef}
className={cn(
"absolute bg-[#0d0e0c] border border-border rounded-none shadow-2xl",
"select-none overflow-hidden flex flex-col",
isMaximized ? "inset-0" : "",
)}
style={{
left: isMaximized ? 0 : position.x,
top: isMaximized ? 0 : position.y,
width: isMaximized ? "100%" : size.width,
height: isMaximized ? "100%" : size.height,
zIndex,
}}
onClick={handleWindowClick}
>
<div
ref={titleBarRef}
className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border shrink-0 cursor-grab active:cursor-grabbing"
onMouseDown={handleMouseDown}
onDoubleClick={handleTitleDoubleClick}
>
<div className="flex items-center gap-3 flex-1 overflow-hidden">
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-accent-brand shrink-0">
{t("fileManager.editor")}
</span>
<div className="h-4 w-px bg-border/50 shrink-0" />
<span className="text-[10px] font-bold tracking-tight text-muted-foreground truncate">
{title}
</span>
</div>
<div className="flex items-center gap-0.5">
{titleActions}
{onMinimize && (
<button
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
onMinimize();
}}
title={t("common.minimize")}
>
<Minus className="size-3.5" />
</button>
)}
{onMaximize && (
<button
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
onMaximize();
}}
title={isMaximized ? t("common.restore") : t("common.maximize")}
>
{isMaximized ? (
<Minimize2 className="size-3.5" />
) : (
<Maximize2 className="size-3.5" />
)}
</button>
)}
<button
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
title={t("common.close")}
>
<X className="size-3.5" />
</button>
</div>
</div>
<div className="flex-1 overflow-hidden">{children}</div>
{!isMaximized && (
<>
<div
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize"
onMouseDown={(e) => handleResizeStart(e, "top")}
/>
<div
className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize"
onMouseDown={(e) => handleResizeStart(e, "bottom")}
/>
<div
className="absolute top-0 bottom-0 left-0 w-1 cursor-w-resize"
onMouseDown={(e) => handleResizeStart(e, "left")}
/>
<div
className="absolute top-0 bottom-0 right-0 w-1 cursor-e-resize"
onMouseDown={(e) => handleResizeStart(e, "right")}
/>
<div
className="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
onMouseDown={(e) => handleResizeStart(e, "top-left")}
/>
<div
className="absolute top-0 right-0 w-2 h-2 cursor-ne-resize"
onMouseDown={(e) => handleResizeStart(e, "top-right")}
/>
<div
className="absolute bottom-0 left-0 w-2 h-2 cursor-sw-resize"
onMouseDown={(e) => handleResizeStart(e, "bottom-left")}
/>
<div
className="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize"
onMouseDown={(e) => handleResizeStart(e, "bottom-right")}
/>
</>
)}
</div>
);
}
@@ -0,0 +1,950 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { Suspense, lazy, useState, useEffect, useRef } from "react";
import { cn } from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
FileText,
Image as ImageIcon,
Film,
Music,
File as FileIcon,
Code,
AlertCircle,
Download,
Eye,
Edit,
Save,
RotateCcw,
Keyboard,
ExternalLink,
Settings,
Search,
ZoomIn,
ZoomOut,
} from "lucide-react";
import {
SiJavascript,
SiTypescript,
SiPython,
SiCplusplus,
SiC,
SiDotnet,
SiPhp,
SiRuby,
SiGo,
SiRust,
SiHtml5,
SiSass,
SiVuedotjs,
SiSvelte,
SiMarkdown,
SiGnubash,
SiMysql,
SiDocker,
} from "react-icons/si";
import { Button } from "@termix/plugin-sdk/ui";
import { Kbd, KbdKey } from "@termix/plugin-sdk/ui";
import type { CodeEditorHandle } from "./CodeEditor.tsx";
import {
loadAudioPreview,
loadCodeEditor,
loadImagePreview,
loadMarkdownRenderer,
loadPdfPreview,
resolveFilePreviewKind,
} from "../file-preview-preload";
const CodeEditor = lazy(loadCodeEditor);
const ImagePreview = lazy(loadImagePreview);
const MarkdownRenderer = lazy(loadMarkdownRenderer);
const PdfPreview = lazy(loadPdfPreview);
const AudioPreview = lazy(loadAudioPreview);
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
interface FileViewerProps {
file: FileItem;
content?: string;
savedContent?: string;
isLoading?: boolean;
isSaving?: boolean;
isEditable?: boolean;
resetKey?: number;
onContentChange?: (content: string) => void;
onSave?: (content: string) => void;
onRevert?: () => void;
onDownload?: () => void;
onOpenExternal?: () => void;
onChooseExternalEditor?: () => void;
onMediaDimensionsChange?: (dimensions: {
width: number;
height: number;
}) => void;
}
function getLanguageIcon(filename: string): React.ReactNode {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase();
if (["dockerfile"].includes(baseName)) {
return <SiDocker className="w-6 h-6 text-blue-400" />;
}
if (["makefile", "rakefile", "gemfile"].includes(baseName)) {
return <SiRuby className="w-6 h-6 text-red-500" />;
}
const iconMap: Record<string, React.ReactNode> = {
js: <SiJavascript className="w-6 h-6 text-yellow-400" />,
jsx: <SiJavascript className="w-6 h-6 text-yellow-400" />,
ts: <SiTypescript className="w-6 h-6 text-blue-500" />,
tsx: <SiTypescript className="w-6 h-6 text-blue-500" />,
py: <SiPython className="w-6 h-6 text-blue-400" />,
java: <Code className="w-6 h-6 text-red-500" />,
cpp: <SiCplusplus className="w-6 h-6 text-blue-600" />,
c: <SiC className="w-6 h-6 text-blue-700" />,
cs: <SiDotnet className="w-6 h-6 text-purple-600" />,
php: <SiPhp className="w-6 h-6 text-indigo-500" />,
rb: <SiRuby className="w-6 h-6 text-red-500" />,
go: <SiGo className="w-6 h-6 text-cyan-500" />,
rs: <SiRust className="w-6 h-6 text-orange-600" />,
html: <SiHtml5 className="w-6 h-6 text-orange-500" />,
css: <Code className="w-6 h-6 text-blue-500" />,
scss: <SiSass className="w-6 h-6 text-pink-500" />,
sass: <SiSass className="w-6 h-6 text-pink-500" />,
less: <Code className="w-6 h-6 text-blue-600" />,
json: <Code className="w-6 h-6 text-yellow-500" />,
xml: <Code className="w-6 h-6 text-orange-500" />,
yaml: <Code className="w-6 h-6 text-red-400" />,
yml: <Code className="w-6 h-6 text-red-400" />,
toml: <Code className="w-6 h-6 text-orange-400" />,
sql: <SiMysql className="w-6 h-6 text-blue-500" />,
sh: <SiGnubash className="w-6 h-6 text-foreground" />,
bash: <SiGnubash className="w-6 h-6 text-foreground" />,
zsh: <Code className="w-6 h-6 text-foreground" />,
vue: <SiVuedotjs className="w-6 h-6 text-green-500" />,
svelte: <SiSvelte className="w-6 h-6 text-orange-500" />,
md: <SiMarkdown className="w-6 h-6 text-muted-foreground" />,
conf: <Code className="w-6 h-6 text-muted-foreground" />,
ini: <Code className="w-6 h-6 text-muted-foreground" />,
};
return iconMap[ext] || <Code className="w-6 h-6 text-yellow-500" />;
}
function getFileType(filename: string): {
type: string;
icon: React.ReactNode;
color: string;
} {
const kind = resolveFilePreviewKind(filename);
if (kind === "image") {
return {
type: "image",
icon: <ImageIcon className="w-6 h-6" />,
color: "text-green-500",
};
} else if (kind === "video") {
return {
type: "video",
icon: <Film className="w-6 h-6" />,
color: "text-purple-500",
};
} else if (kind === "audio") {
return {
type: "audio",
icon: <Music className="w-6 h-6" />,
color: "text-pink-500",
};
} else if (kind === "markdown") {
return {
type: "markdown",
icon: <FileText className="w-6 h-6" />,
color: "text-blue-600",
};
} else if (kind === "pdf") {
return {
type: "pdf",
icon: <FileText className="w-6 h-6" />,
color: "text-red-600",
};
} else if (kind === "text") {
return {
type: "text",
icon: <FileText className="w-6 h-6" />,
color: "text-blue-500",
};
} else if (kind === "code") {
return {
type: "code",
icon: getLanguageIcon(filename),
color: "text-yellow-500",
};
} else {
return {
type: "unknown",
icon: <FileIcon className="w-6 h-6" />,
color: "text-foreground-subtle",
};
}
}
function formatFileSize(bytes?: number, t?: (key: string) => string): string {
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
}
function PreviewFallback({ label }: { label: string }) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">{label}</p>
</div>
</div>
);
}
export function FileViewer({
file,
content = "",
savedContent = "",
isLoading = false,
isSaving = false,
isEditable = false,
resetKey,
onContentChange,
onSave,
onRevert,
onDownload,
onOpenExternal,
onChooseExternalEditor,
onMediaDimensionsChange,
}: FileViewerProps) {
const { t } = useTranslation();
const [editedContent, setEditedContent] = useState(content);
const [, setOriginalContent] = useState(savedContent || content);
const [hasChanges, setHasChanges] = useState(false);
const [showLargeFileWarning, setShowLargeFileWarning] = useState(false);
const [forceShowAsText, setForceShowAsText] = useState(false);
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false);
const [editorFocused, setEditorFocused] = useState(false);
const [markdownEditMode, setMarkdownEditMode] = useState(false);
const [editorFontSize, setEditorFontSize] = useState<number>(() => {
const stored = localStorage.getItem("fileManagerEditorFontSize");
return stored ? parseInt(stored, 10) : 14;
});
const editorRef = useRef<CodeEditorHandle | null>(null);
const MIN_FONT_SIZE = 8;
const MAX_FONT_SIZE = 32;
const decreaseFontSize = () => {
setEditorFontSize((prev) => {
const next = Math.max(MIN_FONT_SIZE, prev - 1);
localStorage.setItem("fileManagerEditorFontSize", String(next));
return next;
});
};
const increaseFontSize = () => {
setEditorFontSize((prev) => {
const next = Math.min(MAX_FONT_SIZE, prev + 1);
localStorage.setItem("fileManagerEditorFontSize", String(next));
return next;
});
};
const fileTypeInfo = getFileType(file.name);
const WARNING_SIZE = 50 * 1024 * 1024;
const MAX_SIZE = Number.MAX_SAFE_INTEGER;
const shouldShowAsText =
fileTypeInfo.type === "text" ||
fileTypeInfo.type === "code" ||
(fileTypeInfo.type === "unknown" &&
(forceShowAsText || !file.size || file.size <= WARNING_SIZE));
const isLargeFile = file.size && file.size > WARNING_SIZE;
const isTooLarge = file.size && file.size > MAX_SIZE;
useEffect(() => {
setEditedContent(content);
if (savedContent) {
setOriginalContent(savedContent);
}
}, [file.name, file.path, resetKey]);
useEffect(() => {
setHasChanges(content !== savedContent);
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
setShowLargeFileWarning(true);
} else {
setShowLargeFileWarning(false);
}
}, [content, savedContent, fileTypeInfo.type, isLargeFile, forceShowAsText]);
const handleContentChange = (newContent: string) => {
setEditedContent(newContent);
setHasChanges(newContent !== savedContent);
onContentChange?.(newContent);
};
const handleSave = () => {
if (isSaving) return;
onSave?.(editedContent);
};
const handleRevert = () => {
if (onRevert) {
onRevert();
} else {
setEditedContent(savedContent);
setHasChanges(false);
}
};
useEffect(() => {
if (!editorFocused || !isEditable) return;
const handleKeyDown = (e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey;
if (isCtrl && e.key.toLowerCase() === "s") {
e.preventDefault();
e.stopPropagation();
handleSave();
}
};
document.addEventListener("keydown", handleKeyDown, true);
return () => {
document.removeEventListener("keydown", handleKeyDown, true);
};
}, [editorFocused, isEditable, handleSave]);
const handleConfirmOpenAsText = () => {
setForceShowAsText(true);
setShowLargeFileWarning(false);
};
const handleCancelOpenAsText = () => {
setShowLargeFileWarning(false);
};
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-sm text-muted-foreground">Loading file...</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-background">
<div className="flex-shrink-0 bg-card border-b border-border p-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className={cn("p-2 rounded-lg bg-muted", fileTypeInfo.color)}>
{fileTypeInfo.icon}
</div>
<div>
<h3 className="font-medium text-foreground">{file.name}</h3>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>{formatFileSize(file.size, t)}</span>
{file.modified && (
<span>
{t("fileManager.modified")}: {file.modified}
</span>
)}
<span
className={cn(
"px-2 py-1 rounded-full text-xs",
fileTypeInfo.color,
"bg-muted",
)}
>
{fileTypeInfo.type.toUpperCase()}
</span>
</div>
</div>
</div>
<div className="ml-auto flex min-w-0 max-w-full flex-wrap items-center justify-end gap-2">
{isEditable && (
<Button
variant="ghost"
size="sm"
onClick={() => editorRef.current?.openSearchPanel()}
className="flex items-center gap-2"
title={t("fileManager.searchInFile")}
>
<Search className="w-4 h-4" />
</Button>
)}
{isEditable && (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={decreaseFontSize}
disabled={editorFontSize <= MIN_FONT_SIZE}
title={t("fileManager.decreaseFontSize")}
className="w-7 h-7 p-0"
>
<ZoomOut className="w-4 h-4" />
</Button>
<span className="text-xs text-muted-foreground w-8 text-center select-none">
{editorFontSize}px
</span>
<Button
variant="ghost"
size="sm"
onClick={increaseFontSize}
disabled={editorFontSize >= MAX_FONT_SIZE}
title={t("fileManager.increaseFontSize")}
className="w-7 h-7 p-0"
>
<ZoomIn className="w-4 h-4" />
</Button>
</div>
)}
{isEditable && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowKeyboardShortcuts(!showKeyboardShortcuts)}
className="flex items-center gap-2"
title={t("fileManager.showKeyboardShortcuts")}
>
<Keyboard className="w-4 h-4" />
</Button>
)}
{hasChanges && (
<>
<Button
variant="outline"
size="sm"
onClick={handleRevert}
disabled={isSaving}
className="flex items-center gap-2"
>
<RotateCcw className="w-4 h-4" />
Revert
</Button>
<Button
variant="default"
size="sm"
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2"
>
<Save className="w-4 h-4" />
Save
</Button>
</>
)}
{onDownload && (
<Button
variant="outline"
size="sm"
onClick={onDownload}
className="flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t("fileManager.download")}
</Button>
)}
{isEditable && onOpenExternal && (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={onOpenExternal}
className="flex items-center gap-2"
>
<ExternalLink className="w-4 h-4" />
{t("fileManager.openExternalEditor")}
</Button>
{onChooseExternalEditor && (
<Button
variant="ghost"
size="sm"
onClick={onChooseExternalEditor}
className="h-8 w-8 p-0"
title={t("fileManager.chooseExternalEditor")}
>
<Settings className="w-4 h-4" />
</Button>
)}
</div>
)}
</div>
</div>
</div>
{showKeyboardShortcuts && isEditable && (
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold">
{t("fileManager.keyboardShortcuts")}
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowKeyboardShortcuts(false)}
className="h-6 w-6 p-0"
>
×
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div className="space-y-2">
<h4 className="font-medium text-muted-foreground">
{t("fileManager.searchAndReplace")}
</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>{t("fileManager.search")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+F
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.replace")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+H
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.findNext")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
F3
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.findPrevious")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Shift+F3
</KbdKey>
</Kbd>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-muted-foreground">
{t("fileManager.editing")}
</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>{t("fileManager.save")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+S
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.selectAll")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+A
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.undo")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Z
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.redo")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Y
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.toggleComment")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+/
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.autoComplete")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Space
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.moveLineUp")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Alt+↑
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.moveLineDown")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Alt+↓
</KbdKey>
</Kbd>
</div>
</div>
</div>
</div>
</div>
)}
<div className="flex-1 overflow-hidden">
{showLargeFileWarning && (
<div className="h-full flex items-center justify-center bg-background">
<div className="bg-card border border-destructive/30 rounded-lg p-6 max-w-md mx-4 shadow-lg">
<div className="flex items-start gap-3 mb-4">
<AlertCircle className="w-6 h-6 text-destructive flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-foreground mb-2">
{t("fileManager.largeFileWarning")}
</h3>
<p className="text-sm text-muted-foreground mb-3">
{t("fileManager.largeFileWarningDesc", {
size: formatFileSize(file.size, t),
})}
</p>
{isTooLarge ? (
<div className="bg-destructive/10 border border-destructive/30 rounded p-3 mb-4">
<p className="text-sm text-destructive font-medium">
File is too large (&gt; 10MB) and cannot be opened as
text for security reasons.
</p>
</div>
) : (
<p className="text-sm text-muted-foreground mb-4">
Do you want to continue opening this file as text? This
may slow down your browser.
</p>
)}
</div>
</div>
<div className="flex gap-3">
{!isTooLarge && (
<Button
variant="default"
size="sm"
onClick={handleConfirmOpenAsText}
className="flex items-center gap-2"
>
<FileText className="w-4 h-4" />
Open as Text
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={onDownload}
className="flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t("fileManager.downloadInstead")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleCancelOpenAsText}
>
Cancel
</Button>
</div>
</div>
</div>
)}
{fileTypeInfo.type === "image" && !showLargeFileWarning && (
<Suspense fallback={<PreviewFallback label="Loading image..." />}>
<ImagePreview
content={content}
fileName={file.name}
onDownload={onDownload}
onMediaDimensionsChange={onMediaDimensionsChange}
/>
</Suspense>
)}
{shouldShowAsText && !showLargeFileWarning && (
<div className="h-full flex flex-col">
{isEditable ? (
<Suspense
fallback={<PreviewFallback label="Loading editor..." />}
>
<CodeEditor
ref={editorRef}
fileName={file.name}
value={editedContent}
onChange={handleContentChange}
onFocus={() => setEditorFocused(true)}
onBlur={() => setEditorFocused(false)}
placeholder={t("fileManager.startTyping")}
fontSize={editorFontSize}
/>
</Suspense>
) : (
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto thin-scrollbar bg-background text-foreground">
{editedContent || content || t("fileManager.fileIsEmpty")}
</div>
)}
</div>
)}
{fileTypeInfo.type === "video" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full">
<div className="w-full max-w-4xl">
{(() => {
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const mimeType = (() => {
switch (ext) {
case "mp4":
return "video/mp4";
case "webm":
return "video/webm";
case "mkv":
return "video/x-matroska";
case "avi":
return "video/x-msvideo";
case "mov":
return "video/quicktime";
case "wmv":
return "video/x-ms-wmv";
case "flv":
return "video/x-flv";
default:
return "video/mp4";
}
})();
const videoUrl = `data:${mimeType};base64,${content}`;
return (
<div className="relative">
<video
controls
className="w-full rounded-lg shadow-sm"
style={{
maxHeight: "calc(100vh - 200px)",
backgroundColor: "#000",
}}
preload="metadata"
onError={(e) => {
console.error(
"Video playback error:",
e.currentTarget.error,
);
}}
onLoadedMetadata={(e) => {
const video = e.currentTarget;
if (
onMediaDimensionsChange &&
video.videoWidth &&
video.videoHeight
) {
onMediaDimensionsChange({
width: video.videoWidth,
height: video.videoHeight,
});
}
}}
>
<source src={videoUrl} type={mimeType} />
<div className="text-center text-muted-foreground p-4">
<AlertCircle className="w-8 h-8 mx-auto mb-2" />
<p>{t("fileManager.videoUnsupported")}</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="mt-2 flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
{t("fileManager.downloadToPlay")}
</Button>
)}
</div>
</video>
</div>
);
})()}
</div>
</div>
)}
{fileTypeInfo.type === "markdown" && !showLargeFileWarning && (
<div className="h-full flex flex-col">
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant={markdownEditMode ? "default" : "outline"}
size="sm"
onClick={() => setMarkdownEditMode(true)}
>
<Edit className="w-4 h-4 mr-1" />
{t("fileManager.edit")}
</Button>
<Button
variant={!markdownEditMode ? "default" : "outline"}
size="sm"
onClick={() => setMarkdownEditMode(false)}
>
<Eye className="w-4 h-4 mr-1" />
{t("fileManager.preview")}
</Button>
</div>
<div className="flex items-center gap-2"></div>
</div>
</div>
<div className="flex-1 flex overflow-hidden">
{markdownEditMode ? (
<>
<div className="flex-1 border-r border-border">
<div className="h-full p-4 bg-background">
<textarea
value={editedContent}
onChange={(e) => {
setEditedContent(e.target.value);
onContentChange?.(e.target.value);
}}
className="w-full h-full resize-none border-0 bg-transparent text-foreground font-mono text-sm leading-relaxed focus:outline-none focus:ring-0"
placeholder={t("fileManager.startWritingMarkdown")}
/>
</div>
</div>
<div className="flex-1 overflow-auto thin-scrollbar bg-muted/10">
<div className="p-4">
<Suspense
fallback={
<PreviewFallback label="Loading preview..." />
}
>
<MarkdownRenderer
compact
content={editedContent || "Nothing to preview yet..."}
/>
</Suspense>
</div>
</div>
</>
) : (
<div className="flex-1 overflow-auto thin-scrollbar p-6">
<div className="max-w-4xl mx-auto">
<Suspense
fallback={<PreviewFallback label="Loading preview..." />}
>
<MarkdownRenderer content={editedContent} />
</Suspense>
</div>
</div>
)}
</div>
</div>
)}
{fileTypeInfo.type === "pdf" && !showLargeFileWarning && (
<Suspense
fallback={<PreviewFallback label="Loading PDF viewer..." />}
>
<PdfPreview
content={content}
onDownload={onDownload}
onMediaDimensionsChange={onMediaDimensionsChange}
/>
</Suspense>
)}
{fileTypeInfo.type === "audio" && !showLargeFileWarning && (
<Suspense
fallback={<PreviewFallback label="Loading audio player..." />}
>
<AudioPreview
file={file}
content={content}
color={fileTypeInfo.color}
onMediaDimensionsChange={onMediaDimensionsChange}
/>
</Suspense>
)}
{fileTypeInfo.type === "unknown" &&
!shouldShowAsText &&
!showLargeFileWarning && (
<div className="h-full flex items-center justify-center">
<div className="text-center text-muted-foreground">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">
{t("fileManager.cannotPreviewType")}
</h3>
<p className="text-sm mb-4">
{t("fileManager.cannotPreviewTypeHint")}
</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
{t("fileManager.downloadFile")}
</Button>
)}
</div>
</div>
)}
</div>
<div className="flex-shrink-0 bg-muted/50 border-t border-border px-4 py-2 text-xs text-muted-foreground">
<div className="flex justify-between items-center">
<span>{file.path}</span>
{hasChanges && (
<span className="text-orange-600 font-medium">
● Unsaved changes
</span>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,604 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useState, useEffect, useRef } from "react";
import { DraggableWindow } from "./DraggableWindow.tsx";
import { FileViewer } from "./FileViewer.tsx";
import { useWindowManager } from "./WindowManager.tsx";
import {
downloadSSHFileStream,
readSSHFile,
writeSSHFile,
getSSHStatus,
connectSSH,
setSudoPassword,
} from "../api/ssh-file-operations-api";
import { SudoPasswordDialog } from "../SudoPasswordDialog.tsx";
import { toast } from "sonner";
import { DownloadProgressToast } from "./DownloadProgressToast";
import type { SSHHost } from "../host-types";
import { useTranslation } from "@termix/plugin-sdk/frontend";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
interface FileWindowProps {
windowId: string;
file: FileItem;
sshSessionId: string;
sshHost: SSHHost;
initialX?: number;
initialY?: number;
onFileNotFound?: (file: FileItem) => void;
}
function isDisplayableText(str: string): boolean {
let printable = 0;
for (let i = 0; i < Math.min(str.length, 1000); i++) {
const code = str.charCodeAt(i);
if (
(code >= 32 && code <= 126) ||
code === 9 ||
code === 10 ||
code === 13 ||
code >= 128
) {
printable++;
}
}
return printable / Math.min(str.length, 1000) > 0.85;
}
export function FileWindow({
windowId,
file,
sshSessionId,
sshHost,
initialX = 100,
initialY = 100,
onFileNotFound,
}: FileWindowProps) {
const { closeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const { t } = useTranslation();
const [content, setContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isEditable, setIsEditable] = useState(false);
const [pendingContent, setPendingContent] = useState<string>("");
const [resetKey, setResetKey] = useState(0);
const [mediaDimensions, setMediaDimensions] = useState<
{ width: number; height: number } | undefined
>();
const [externalEditorPath, setExternalEditorPath] = useState<string>("");
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const externalEditorRef = useRef<{
editId: string;
unsubscribe?: () => void;
} | null>(null);
const currentWindow = windows.find((w) => w.id === windowId);
useEffect(() => {
if (!window.electronAPI?.isElectron) return;
window.electronAPI
.getSetting?.("fileManager.externalEditorPath")
.then((value) => {
if (typeof value === "string") setExternalEditorPath(value);
})
.catch(() => {});
}, []);
const ensureSSHConnection = async () => {
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
}
} catch (error) {
console.error("SSH connection check/reconnect failed:", error);
throw error;
}
};
useEffect(() => {
const loadFileContent = async () => {
if (file.type !== "file") return;
try {
setIsLoading(true);
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path);
let fileContent = response.content || "";
if (response.encoding === "base64") {
try {
const bytes = Uint8Array.from(atob(fileContent), (c) =>
c.charCodeAt(0),
);
const decoded = new TextDecoder("utf-8").decode(bytes);
if (isDisplayableText(decoded)) {
fileContent = decoded;
}
} catch (err) {
console.error("Failed to decode base64 content:", err);
}
}
setContent(fileContent);
setPendingContent(fileContent);
setResetKey((k) => k + 1);
if (!file.size) {
const contentSize = new Blob([fileContent]).size;
file.size = contentSize;
}
const mediaExtensions = [
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"svg",
"webp",
"tiff",
"ico",
"mp3",
"wav",
"ogg",
"aac",
"flac",
"m4a",
"wma",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"mkv",
"webm",
"m4v",
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"xz",
"exe",
"dll",
"so",
"dylib",
"bin",
"iso",
];
const extension = file.name.split(".").pop()?.toLowerCase();
setIsEditable(!mediaExtensions.includes(extension || ""));
} catch (error: unknown) {
console.error("Failed to load file:", error);
const err = error as {
message?: string;
isFileNotFound?: boolean;
response?: {
status?: number;
data?: {
tooLarge?: boolean;
error?: string;
fileNotFound?: boolean;
};
};
};
const errorData = err?.response?.data;
if (errorData?.tooLarge) {
toast.error(
t("fileManager.fileTooLarge", { error: errorData.error }),
{
duration: 10000,
},
);
} else if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
const errorMessage =
errorData?.error || err.message || "Unknown error";
const isFileNotFound =
err.isFileNotFound ||
errorData?.fileNotFound ||
err.response?.status === 404 ||
errorMessage.includes("File not found") ||
errorMessage.includes("No such file or directory") ||
errorMessage.includes("cannot access") ||
errorMessage.includes("not found") ||
errorMessage.includes("Resource not found");
if (isFileNotFound && onFileNotFound) {
onFileNotFound(file);
toast.error(
t("fileManager.fileNotFoundAndRemoved", { name: file.name }),
);
closeWindow(windowId);
return;
} else {
toast.error(
t("fileManager.failedToLoadFile", {
error: errorMessage.includes("Server error occurred")
? t("fileManager.serverErrorOccurred")
: errorMessage,
}),
);
}
}
} finally {
setIsLoading(false);
}
};
loadFileContent();
}, [file, sshSessionId, sshHost]);
const handleRevert = async () => {
const loadFileContent = async () => {
if (file.type !== "file") return;
try {
setIsLoading(true);
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path, {
force: true,
});
const fileContent = response.content || "";
setContent(fileContent);
setPendingContent("");
setResetKey((k) => k + 1);
if (!file.size) {
const contentSize = new Blob([fileContent]).size;
file.size = contentSize;
}
} catch (error: unknown) {
console.error("Failed to load file content:", error);
const err = error as { message?: string };
toast.error(
`${t("fileManager.failedToLoadFile")}: ${err.message || t("fileManager.unknownError")}`,
);
} finally {
setIsLoading(false);
}
};
loadFileContent();
};
const [sudoSaveContent, setSudoSaveContent] = useState<string | null>(null);
const handleSudoSave = async (password: string) => {
const pending = sudoSaveContent;
setSudoSaveContent(null);
if (pending === null) return;
try {
await setSudoPassword(sshSessionId, password);
} catch {
toast.error(t("fileManager.sudoAuthFailed"));
return;
}
await handleSave(pending);
};
const handleSave = async (newContent: string) => {
try {
setIsSaving(true);
await ensureSSHConnection();
await writeSSHFile(sshSessionId, file.path, newContent);
setContent(newContent);
setPendingContent("");
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
toast.success(t("fileManager.fileSavedSuccessfully"));
} catch (error: unknown) {
console.error("Failed to save file:", error);
const err = error as {
message?: string;
response?: { data?: { needsSudo?: boolean } };
};
if (err.response?.data?.needsSudo) {
setSudoSaveContent(newContent);
} else if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
toast.error(
t("fileManager.sshConnectionFailed", {
name: sshHost.name,
ip: sshHost.ip,
port: sshHost.port,
}),
);
} else {
toast.error(
`${t("fileManager.failedToSaveFile")}: ${err.message || t("fileManager.unknownError")}`,
);
}
} finally {
setIsSaving(false);
}
};
const closeExternalEditor = async () => {
const session = externalEditorRef.current;
if (!session) return;
session.unsubscribe?.();
externalEditorRef.current = null;
try {
await window.electronAPI?.closeExternalEditor?.(session.editId);
} catch (error) {
console.error("Failed to close external editor session:", error);
}
};
const handleOpenExternalEditor = async () => {
if (!window.electronAPI?.isElectron) {
toast.error(t("fileManager.externalEditorDesktopOnly"));
return;
}
try {
await closeExternalEditor();
const result = await window.electronAPI.openExternalEditor({
fileName: file.name,
content: pendingContent || content,
encoding: "utf8",
editorPath: externalEditorPath || null,
});
if (!result.success || !result.editId) {
throw new Error(result.error || "Failed to open external editor");
}
const unsubscribe = window.electronAPI.onExternalEditorSaved?.(
(payload) => {
if (payload.editId !== result.editId) return;
void handleSave(payload.content);
},
);
externalEditorRef.current = {
editId: result.editId,
unsubscribe,
};
toast.success(t("fileManager.externalEditorOpened"));
} catch (error: unknown) {
console.error("Failed to open external editor:", error);
const err = error as { message?: string };
toast.error(
`${t("fileManager.failedToOpenExternalEditor")}: ${
err.message || t("fileManager.unknownError")
}`,
);
}
};
const handleChooseExternalEditor = async () => {
if (!window.electronAPI?.isElectron) {
toast.error(t("fileManager.externalEditorDesktopOnly"));
return;
}
try {
const result = await window.electronAPI.showOpenDialog({
title: t("fileManager.chooseExternalEditor"),
properties: ["openFile"],
});
const editorPath = result.filePaths?.[0];
if (result.canceled || !editorPath) return;
await window.electronAPI.setSetting?.(
"fileManager.externalEditorPath",
editorPath,
);
setExternalEditorPath(editorPath);
toast.success(t("fileManager.externalEditorSelected"));
} catch (error: unknown) {
console.error("Failed to select external editor:", error);
const err = error as { message?: string };
toast.error(
`${t("fileManager.failedToSelectExternalEditor")}: ${
err.message || t("fileManager.unknownError")
}`,
);
}
};
const handleContentChange = (newContent: string) => {
setPendingContent(newContent);
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
if (newContent !== content) {
autoSaveTimerRef.current = setTimeout(async () => {
try {
await handleSave(newContent);
toast.success(t("fileManager.fileAutoSaved"));
} catch (error) {
console.error("Auto-save failed:", error);
toast.error(t("fileManager.autoSaveFailed"));
}
}, 60000);
}
};
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
void closeExternalEditor();
};
}, []);
const handleDownload = async () => {
const toastId = toast.loading(
<DownloadProgressToast fileName={file.name} loaded={0} />,
{ duration: Infinity },
);
try {
await ensureSSHConnection();
await downloadSSHFileStream(
sshSessionId,
file.path,
({ loaded, total }) => {
toast.loading(
<DownloadProgressToast
fileName={file.name}
loaded={loaded}
total={total}
/>,
{ id: toastId, duration: Infinity },
);
},
);
toast.success(
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
{ id: toastId, duration: undefined },
);
} catch (error: unknown) {
console.error("Failed to download file:", error);
const err = error as { message?: string };
if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
{ id: toastId, duration: undefined },
);
} else {
toast.error(
`Failed to download file: ${err.message || "Unknown error"}`,
{ id: toastId, duration: undefined },
);
}
}
};
const handleClose = () => {
void closeExternalEditor();
closeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
};
const handleFocus = () => {
focusWindow(windowId);
};
const handleMediaDimensionsChange = (dimensions: {
width: number;
height: number;
}) => {
setMediaDimensions(dimensions);
};
if (!currentWindow) {
return null;
}
return (
<DraggableWindow
title={file.name}
initialX={initialX}
initialY={initialY}
initialWidth={800}
initialHeight={600}
minWidth={400}
minHeight={300}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
targetSize={mediaDimensions}
>
<FileViewer
file={file}
content={pendingContent || content}
savedContent={content}
isLoading={isLoading}
isSaving={isSaving}
resetKey={resetKey}
onRevert={handleRevert}
isEditable={isEditable}
onContentChange={handleContentChange}
onSave={(newContent) => handleSave(newContent)}
onDownload={handleDownload}
onOpenExternal={
window.electronAPI?.isElectron && isEditable
? handleOpenExternalEditor
: undefined
}
onChooseExternalEditor={
window.electronAPI?.isElectron && isEditable
? handleChooseExternalEditor
: undefined
}
onMediaDimensionsChange={handleMediaDimensionsChange}
/>
<SudoPasswordDialog
open={sudoSaveContent !== null}
onOpenChange={(open) => {
if (!open) setSudoSaveContent(null);
}}
onSubmit={(password) => void handleSudoSave(password)}
/>
</DraggableWindow>
);
}
@@ -0,0 +1,668 @@
"use client";
import React, {
useState,
useCallback,
createContext,
useContext,
useRef,
useEffect,
} from "react";
import {
motion,
AnimatePresence,
easeInOut,
type Variants,
} from "motion/react";
import {
ChevronRight,
Folder,
FolderOpen,
File,
type LucideIcon,
} from "lucide-react";
import { cn } from "@termix/plugin-sdk/ui";
const animationVariants: Variants = {
rootInitial: { opacity: 0, y: 20 },
rootAnimate: { opacity: 1, y: 0 },
itemInitial: { opacity: 0, x: -10 },
itemAnimate: { opacity: 1, x: 0 },
contentHidden: { opacity: 0, height: 0 },
contentVisible: { opacity: 1, height: "auto" },
chevronClosed: { rotate: 0 },
chevronOpen: { rotate: 90 },
};
const transitions = {
root: { duration: 0.4 },
item: { duration: 0.2 },
content: { duration: 0.3, ease: easeInOut },
chevron: { duration: 0.2 },
};
interface ExpansionContextType {
expandedIds: Set<string>;
toggleExpanded: (id: string) => void;
}
interface SelectionContextType {
selectedId: string | null;
setSelected: (id: string) => void;
onSelect?: (id: string, label: string) => void;
}
interface TreeContextType {
focusedId: string | null;
setFocusedId: (id: string | null) => void;
treeId: string;
setKeyboardMode: (mode: boolean) => void;
keyboardMode: boolean;
}
interface LevelContextType {
level: number;
}
const ExpansionContext = createContext<ExpansionContextType | null>(null);
const SelectionContext = createContext<SelectionContextType | null>(null);
const TreeContext = createContext<TreeContextType | null>(null);
const LevelContext = createContext<LevelContextType>({ level: 0 });
const useExpansion = () => {
const context = useContext(ExpansionContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useSelection = () => {
const context = useContext(SelectionContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useTree = () => {
const context = useContext(TreeContext);
if (!context) {
throw new Error(
"FolderTree components must be used within FolderTree.Root",
);
}
return context;
};
const useLevel = () => {
return useContext(LevelContext);
};
const getPaddingClass = (level: number): string => {
const paddingMap: Record<number, string> = {
0: "pl-3",
1: "pl-8",
2: "pl-12",
3: "pl-16",
4: "pl-20",
5: "pl-24",
6: "pl-28",
7: "pl-32",
};
return paddingMap[level] || `pl-[${Math.min(level * 4 + 12, 48)}px]`;
};
interface CustomBadge {
content: React.ReactNode;
className?: string;
ariaLabel?: string;
}
interface RootProps {
defaultExpanded?: string[];
defaultSelected?: string;
selectedId?: string | null;
expandedIds?: Set<string>;
onSelect?: (id: string, label: string) => void;
className?: string;
children: React.ReactNode;
id?: string;
}
interface ItemProps {
id: string;
label: string;
icon?: LucideIcon;
badge?: string | number;
modified?: boolean | CustomBadge;
untracked?: boolean | CustomBadge;
className?: string;
children?: React.ReactNode;
}
interface TriggerProps {
className?: string;
}
interface ContentProps {
children: React.ReactNode;
className?: string;
}
const Root: React.FC<RootProps> = ({
defaultExpanded = [],
defaultSelected,
selectedId: controlledSelectedId,
expandedIds: additionalExpandedIds,
onSelect,
className = "",
children,
id = "folder-tree",
}) => {
const [expandedIds, setExpandedIds] = useState<Set<string>>(
new Set(defaultExpanded),
);
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(
defaultSelected || null,
);
const selectedId =
controlledSelectedId !== undefined
? controlledSelectedId
: internalSelectedId;
const [focusedId, setFocusedId] = useState<string | null>(null);
const [keyboardMode, setKeyboardMode] = useState(false);
const treeRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!additionalExpandedIds || additionalExpandedIds.size === 0) return;
setExpandedIds((prev) => {
const merged = new Set(prev);
let changed = false;
for (const id of additionalExpandedIds) {
if (!merged.has(id)) {
merged.add(id);
changed = true;
}
}
return changed ? merged : prev;
});
}, [additionalExpandedIds]);
const toggleExpanded = useCallback((id: string) => {
setExpandedIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const setSelected = useCallback((id: string) => {
setInternalSelectedId(id);
}, []);
const getVisibleItemIds = useCallback(() => {
const items = Array.from(
treeRef.current?.querySelectorAll('[role="treeitem"]') || [],
);
return items
.filter((item) => {
const element = item as HTMLElement;
return element.offsetHeight > 0 && element.offsetWidth > 0;
})
.map((item) => item.getAttribute("data-id"))
.filter(Boolean) as string[];
}, []);
const [treeHasFocus, setTreeHasFocus] = useState(false);
const handleTreeFocus = useCallback(() => {
if (!treeHasFocus) {
setTreeHasFocus(true);
setKeyboardMode(true);
}
}, [treeHasFocus]);
const handleTreeBlur = useCallback((e: React.FocusEvent) => {
if (!treeRef.current?.contains(e.relatedTarget as Node)) {
setTreeHasFocus(false);
setFocusedId(null);
setKeyboardMode(false);
}
}, []);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const getVisibleItems = () => {
return Array.from(
treeRef.current?.querySelectorAll('[role="treeitem"]') || [],
).filter((item) => {
const element = item as HTMLElement;
return element.offsetHeight > 0 && element.offsetWidth > 0;
});
};
if (e.key === "Tab") {
if (treeHasFocus && !focusedId) {
const visibleItemIds = getVisibleItemIds();
if (visibleItemIds.length > 0) {
setFocusedId(visibleItemIds[0]);
e.preventDefault();
return;
}
}
if (focusedId) {
const visibleItems = getVisibleItems();
const currentIndex = visibleItems.findIndex(
(item) => item.getAttribute("data-id") === focusedId,
);
if (e.shiftKey) {
if (currentIndex === 0) {
setFocusedId(null);
setTreeHasFocus(false);
setKeyboardMode(false);
return;
}
const nextIndex = Math.max(0, currentIndex - 1);
const nextItem = visibleItems[nextIndex] as HTMLElement;
const nextId = nextItem?.getAttribute("data-id");
if (nextId) {
setFocusedId(nextId);
e.preventDefault();
}
} else {
if (currentIndex === visibleItems.length - 1) {
setFocusedId(null);
setTreeHasFocus(false);
setKeyboardMode(false);
return;
}
const nextIndex = Math.min(
visibleItems.length - 1,
currentIndex + 1,
);
const nextItem = visibleItems[nextIndex] as HTMLElement;
const nextId = nextItem?.getAttribute("data-id");
if (nextId) {
setFocusedId(nextId);
e.preventDefault();
}
}
}
return;
}
if (!keyboardMode || !focusedId) return;
const visibleItems = getVisibleItems();
const currentIndex = visibleItems.findIndex(
(item) => item.getAttribute("data-id") === focusedId,
);
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (currentIndex < visibleItems.length - 1) {
const nextItem = visibleItems[currentIndex + 1] as HTMLElement;
const nextId = nextItem.getAttribute("data-id");
if (nextId) setFocusedId(nextId);
}
break;
case "ArrowUp":
e.preventDefault();
if (currentIndex > 0) {
const prevItem = visibleItems[currentIndex - 1] as HTMLElement;
const prevId = prevItem.getAttribute("data-id");
if (prevId) setFocusedId(prevId);
}
break;
case "ArrowRight":
e.preventDefault();
if (!expandedIds.has(focusedId)) {
toggleExpanded(focusedId);
}
break;
case "ArrowLeft":
e.preventDefault();
if (expandedIds.has(focusedId)) {
toggleExpanded(focusedId);
}
break;
case "Enter":
case " ":
e.preventDefault();
setSelected(focusedId);
if (onSelect) {
const currentItem = visibleItems[currentIndex] as HTMLElement;
const label =
currentItem.querySelector("span:nth-of-type(2)")?.textContent ||
"";
onSelect(focusedId, label);
}
break;
}
},
[
focusedId,
keyboardMode,
expandedIds,
toggleExpanded,
setSelected,
onSelect,
getVisibleItemIds,
treeHasFocus,
],
);
useEffect(() => {
const handleMouseDown = () => setKeyboardMode(false);
document.addEventListener("mousedown", handleMouseDown);
return () => {
document.removeEventListener("mousedown", handleMouseDown);
};
}, []);
const expansionValue: ExpansionContextType = {
expandedIds,
toggleExpanded,
};
const selectionValue: SelectionContextType = {
selectedId,
setSelected,
onSelect,
};
const treeValue: TreeContextType = {
focusedId,
setFocusedId,
treeId: id,
setKeyboardMode,
keyboardMode,
};
return (
<ExpansionContext.Provider value={expansionValue}>
<SelectionContext.Provider value={selectionValue}>
<TreeContext.Provider value={treeValue}>
<LevelContext.Provider value={{ level: 0 }}>
<motion.div
ref={treeRef}
variants={animationVariants}
initial="rootInitial"
animate="rootAnimate"
transition={transitions.root}
className={cn(
"bg-canvas border border-edge rounded-lg overflow-hidden",
className,
)}
role="tree"
aria-labelledby={`${id}-label`}
tabIndex={0}
onKeyDown={handleKeyDown}
onFocus={handleTreeFocus}
onBlur={handleTreeBlur}
>
<div className="w-full overflow-y-auto bg-canvas text-sm">
{children}
</div>
</motion.div>
</LevelContext.Provider>
</TreeContext.Provider>
</SelectionContext.Provider>
</ExpansionContext.Provider>
);
};
const ItemContext = createContext<{
itemId: string;
hasChildren: boolean;
isExpanded: boolean;
toggleExpanded: () => void;
} | null>(null);
const Item: React.FC<ItemProps> = ({
id,
label,
icon,
badge,
modified,
untracked,
className = "",
children,
}) => {
const expansionContext = useExpansion();
const selectionContext = useSelection();
const treeContext = useTree();
const { level } = useLevel();
const itemRef = useRef<HTMLDivElement>(null);
const keyboardMode = treeContext.keyboardMode;
const hasChildren = React.Children.count(children) > 0;
const isExpanded = expansionContext.expandedIds.has(id);
const isSelected = selectionContext.selectedId === id;
const isFocused = treeContext.focusedId === id;
const handleItemClick = useCallback(() => {
treeContext.setKeyboardMode(false);
selectionContext.setSelected(id);
treeContext.setFocusedId(id);
if (selectionContext.onSelect) {
selectionContext.onSelect(id, label);
}
}, [id, label, selectionContext, treeContext]);
const toggleExpanded = useCallback(() => {
if (hasChildren) {
expansionContext.toggleExpanded(id);
}
}, [id, hasChildren, expansionContext]);
const handleFocus = useCallback(() => {
treeContext.setFocusedId(id);
}, [id, treeContext]);
useEffect(() => {
if (isFocused && itemRef.current) {
itemRef.current.focus();
}
}, [isFocused]);
const IconComponent =
icon || (hasChildren ? (isExpanded ? FolderOpen : Folder) : File);
const itemContextValue = {
itemId: id,
hasChildren,
isExpanded,
toggleExpanded,
};
const renderBadge = (
badgeData: boolean | CustomBadge | undefined,
defaultContent: string,
defaultClassName: string,
) => {
if (!badgeData) return null;
if (typeof badgeData === "boolean") {
return (
<span
className={defaultClassName}
aria-label={`${defaultContent} status`}
>
{defaultContent}
</span>
);
}
return (
<span
className={cn(
"ml-auto text-xs px-2 py-0.5 rounded-full",
badgeData.className,
)}
aria-label={badgeData.ariaLabel || `Custom badge: ${badgeData.content}`}
>
{badgeData.content}
</span>
);
};
return (
<ItemContext.Provider value={itemContextValue}>
<LevelContext.Provider value={{ level: level + 1 }}>
<div>
<motion.div
ref={itemRef}
variants={animationVariants}
initial="itemInitial"
animate="itemAnimate"
transition={{ ...transitions.item, delay: level * 0.05 }}
data-selected={isSelected ? "true" : "false"}
data-id={id}
className={cn(
"flex items-center gap-2 py-1.5 text-sm transition-colors cursor-pointer select-none",
getPaddingClass(level),
className,
isSelected
? "bg-accent text-accent-foreground border-r-2 border-ring"
: "",
!isSelected && "hover:bg-hover",
keyboardMode && isFocused
? "focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-inset"
: "focus:outline-hidden",
)}
onClick={(e: React.MouseEvent) => {
handleItemClick();
e.stopPropagation();
toggleExpanded();
}}
onFocus={handleFocus}
role="treeitem"
tabIndex={isFocused ? 0 : -1}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-selected={isSelected}
aria-label={`${hasChildren ? "Folder" : "File"}: ${label}`}
aria-level={level + 1}
>
{hasChildren && (
<motion.span
className="shrink-0 cursor-pointer"
variants={animationVariants}
animate={isExpanded ? "chevronOpen" : "chevronClosed"}
transition={transitions.chevron}
aria-hidden="true"
>
<ChevronRight size={14} className="text-muted-foreground" />
</motion.span>
)}
{!hasChildren && <span className="w-3 mr-2" aria-hidden="true" />}
{IconComponent && (
<IconComponent
size={16}
data-selected={isSelected ? "true" : "false"}
data-child={hasChildren ? "true" : "false"}
className={cn(
"mr-1 shrink-0 text-muted-foreground data-[child=true]:text-primary data-[selected=true]:text-accent-foreground",
)}
aria-hidden="true"
/>
)}
<span className="flex-1">{label}</span>
{badge && (
<span
className="ml-auto text-xs bg-muted text-muted-foreground px-2 py-0.5 rounded-full"
aria-label={`Badge: ${badge}`}
>
{badge}
</span>
)}
{renderBadge(
modified,
"M",
"ml-auto text-xs bg-yellow-200 dark:bg-yellow-700 text-yellow-800 dark:text-yellow-200 px-2 py-0.5 rounded-full",
)}
{renderBadge(
untracked,
"U",
"ml-auto text-xs bg-green-200 dark:bg-green-700 text-green-800 dark:text-green-200 px-2 py-0.5 rounded-full",
)}
</motion.div>
{children}
</div>
</LevelContext.Provider>
</ItemContext.Provider>
);
};
const Trigger: React.FC<TriggerProps> = ({ className = "" }) => {
const itemContext = useContext(ItemContext);
if (!itemContext || !itemContext.hasChildren) {
return null;
}
return (
<motion.span
className={cn("mr-2 shrink-0 cursor-pointer", className)}
variants={animationVariants}
animate={itemContext.isExpanded ? "chevronOpen" : "chevronClosed"}
transition={transitions.chevron}
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
itemContext.toggleExpanded();
}}
role="button"
aria-label={itemContext.isExpanded ? "Collapse" : "Expand"}
tabIndex={-1}
>
<ChevronRight size={14} className="text-muted-foreground" />
</motion.span>
);
};
const Content: React.FC<ContentProps> = ({ children, className = "" }) => {
const itemContext = useContext(ItemContext);
if (!itemContext) {
return <>{children}</>;
}
const hasContent = React.Children.count(children) > 0;
return (
<AnimatePresence>
{hasContent && itemContext.isExpanded && (
<motion.div
variants={animationVariants}
initial="contentHidden"
animate="contentVisible"
exit="contentHidden"
transition={transitions.content}
style={{ overflow: "hidden" }}
className={className}
role="group"
>
{children}
</motion.div>
)}
</AnimatePresence>
);
};
const FolderTree = {
Root,
Item,
Trigger,
Content,
};
export default FolderTree;
@@ -0,0 +1,112 @@
import React, { useState } from "react";
import { PhotoProvider, PhotoView } from "react-photo-view";
import "react-photo-view/dist/react-photo-view.css";
import { AlertCircle, Download } from "lucide-react";
import { Button } from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
interface ImagePreviewProps {
content: string;
fileName: string;
onDownload?: () => void;
onMediaDimensionsChange?: (dimensions: {
width: number;
height: number;
}) => void;
}
function getImageDataUrl(content: string, fileName: string): string {
const ext = fileName.split(".").pop()?.toLowerCase() || "";
const mimeTypes: Record<string, string> = {
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
tiff: "image/tiff",
tif: "image/tiff",
};
const mimeType = mimeTypes[ext] || "image/png";
return `data:${mimeType};base64,${content}`;
}
export function ImagePreview({
content,
fileName,
onDownload,
onMediaDimensionsChange,
}: ImagePreviewProps) {
const { t } = useTranslation();
const [imageLoadError, setImageLoadError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const imageUrl = getImageDataUrl(content, fileName);
return (
<div className="p-6 flex items-center justify-center h-full relative">
{imageLoadError ? (
<div className="text-center text-muted-foreground">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">
{t("fileManager.imageLoadError")}
</h3>
<p className="text-sm mb-4">{fileName}</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
{t("fileManager.download")}
</Button>
)}
</div>
) : (
<PhotoProvider maskOpacity={0.7}>
<PhotoView src={imageUrl}>
<img
src={imageUrl}
alt={fileName}
className="max-w-full max-h-full object-contain rounded-lg shadow-sm cursor-pointer hover:shadow-lg transition-shadow"
style={{ maxHeight: "calc(100vh - 200px)" }}
onLoad={(e) => {
setImageLoading(false);
setImageLoadError(false);
const img = e.currentTarget;
if (
onMediaDimensionsChange &&
img.naturalWidth &&
img.naturalHeight
) {
onMediaDimensionsChange({
width: img.naturalWidth,
height: img.naturalHeight,
});
}
}}
onError={() => {
setImageLoading(false);
setImageLoadError(true);
}}
/>
</PhotoView>
</PhotoProvider>
)}
{imageLoading && !imageLoadError && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">Loading image...</p>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,109 @@
import { Button } from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { formatFileSize } from "../file-manager-utils.ts";
export interface LocalTransferBatchStatus {
direction: "upload" | "download";
totalFiles: number;
completedFiles: number;
currentFileName?: string;
/** Files being transferred right now (parallel transfers). */
activeFiles?: number;
bytesDone: number;
totalBytes: number;
mbPerSec?: number;
cancelling?: boolean;
}
interface LocalTransferProgressToastProps {
status: LocalTransferBatchStatus;
onCancel?: () => void;
}
export function LocalTransferProgressToast({
status,
onCancel,
}: LocalTransferProgressToastProps) {
const { t } = useTranslation();
const percent =
status.totalBytes > 0
? Math.min(100, Math.round((status.bytesDone / status.totalBytes) * 100))
: undefined;
const title =
status.direction === "upload"
? t("fileManager.localUploadingProgress", {
current: Math.min(
status.completedFiles + Math.max(1, status.activeFiles ?? 1),
status.totalFiles,
),
total: status.totalFiles,
})
: t("fileManager.localDownloadingProgress", {
current: Math.min(
status.completedFiles + Math.max(1, status.activeFiles ?? 1),
status.totalFiles,
),
total: status.totalFiles,
});
return (
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium leading-tight">{title}</p>
{status.currentFileName && (
<p
className="text-xs text-muted-foreground truncate"
title={status.currentFileName}
>
{(status.activeFiles ?? 0) > 1 && (
<span className="mr-2.5 text-foreground/70">
{t("fileManager.localTransferInFlight", {
count: status.activeFiles,
})}
</span>
)}
{status.currentFileName}
</p>
)}
</div>
{onCancel && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 px-2 text-xs"
disabled={status.cancelling}
onClick={onCancel}
>
{status.cancelling
? t("transfer.progressCancelling")
: t("transfer.progressCancel")}
</Button>
)}
</div>
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
{percent === undefined ? (
<div className="bg-primary/60 absolute inset-y-0 left-0 w-1/3 animate-pulse rounded-full" />
) : (
<div
className="bg-primary h-full rounded-full transition-[width]"
style={{ width: `${percent}%` }}
/>
)}
</div>
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
<span className="min-w-0 truncate">
{formatFileSize(status.bytesDone)} /{" "}
{formatFileSize(status.totalBytes)}
</span>
<span className="shrink-0 tabular-nums font-medium text-foreground">
{status.mbPerSec !== undefined
? `${status.mbPerSec.toFixed(status.mbPerSec >= 10 ? 0 : 1)} MB/s`
: ""}
</span>
</div>
</div>
);
}
@@ -0,0 +1,134 @@
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark as syntaxTheme } from "react-syntax-highlighter/dist/esm/styles/prism";
interface MarkdownRendererProps {
content: string;
compact?: boolean;
}
export function MarkdownRenderer({
content,
compact = false,
}: MarkdownRendererProps) {
const h1Class = compact
? "text-2xl font-bold mb-4 mt-6 text-foreground border-b border-border pb-2"
: "text-3xl font-bold mb-6 mt-8 text-foreground border-b border-border pb-2";
const h2Class = compact
? "text-xl font-semibold mb-3 mt-5 text-foreground border-b border-border pb-1"
: "text-2xl font-semibold mb-4 mt-6 text-foreground border-b border-border pb-1";
const h3Class = compact
? "text-lg font-semibold mb-2 mt-4 text-foreground"
: "text-xl font-semibold mb-3 mt-4 text-foreground";
const h4Class = compact
? "text-base font-semibold mb-2 mt-3 text-foreground"
: "text-lg font-semibold mb-2 mt-3 text-foreground";
const pClass = compact
? "mb-3 text-foreground leading-relaxed"
: "mb-4 text-foreground leading-relaxed";
const listClass = compact
? "mb-3 ml-4 text-foreground"
: "mb-4 ml-6 text-foreground";
const quoteClass = compact
? "border-l-4 border-blue-500 pl-3 mb-3 italic text-muted-foreground bg-muted/30 py-1"
: "border-l-4 border-blue-500 pl-4 mb-4 italic text-muted-foreground bg-muted/30 py-2";
const tableWrapClass = compact
? "mb-3 overflow-x-auto thin-scrollbar"
: "mb-4 overflow-x-auto thin-scrollbar";
const tableClass = compact
? "min-w-full border border-border rounded-lg text-sm"
: "min-w-full border border-border rounded-lg";
const thClass = compact
? "px-3 py-2 text-left font-semibold text-foreground"
: "px-4 py-2 text-left font-semibold text-foreground";
const tdClass = compact
? "px-3 py-2 text-foreground"
: "px-4 py-2 text-foreground";
return (
<div
className="select-text"
tabIndex={0}
onKeyDown={(event) => {
// Keep native text copying out of the file grid's global shortcuts.
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "c")
event.stopPropagation();
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
// v9 removed the `inline` flag; a fenced block always carries a
// language- class, an inline span never does.
const inline = !className;
return !inline && match ? (
<SyntaxHighlighter
style={syntaxTheme}
language={match[1]}
PreTag="div"
className="rounded-lg"
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
{...props}
>
{children}
</code>
);
},
h1: ({ children }) => <h1 className={h1Class}>{children}</h1>,
h2: ({ children }) => <h2 className={h2Class}>{children}</h2>,
h3: ({ children }) => <h3 className={h3Class}>{children}</h3>,
h4: ({ children }) => <h4 className={h4Class}>{children}</h4>,
p: ({ children }) => <p className={pClass}>{children}</p>,
ul: ({ children }) => (
<ul className={`${listClass} list-disc`}>{children}</ul>
),
ol: ({ children }) => (
<ol className={`${listClass} list-decimal`}>{children}</ol>
),
li: ({ children }) => (
<li className="mb-1 text-foreground">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className={quoteClass}>{children}</blockquote>
),
table: ({ children }) => (
<div className={tableWrapClass}>
<table className={tableClass}>{children}</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-muted">{children}</thead>
),
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => (
<tr className="border-b border-border">{children}</tr>
),
th: ({ children }) => <th className={thClass}>{children}</th>,
td: ({ children }) => <td className={tdClass}>{children}</td>,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline"
>
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
</div>
);
}
@@ -0,0 +1,43 @@
import type React from "react";
import { cn } from "@termix/plugin-sdk/ui";
interface PaneResizeHandleProps {
label: string;
onMouseDown: (event: React.MouseEvent) => void;
onDoubleClick?: () => void;
active?: boolean;
}
/**
* Vertical drag handle placed between two panes in a `gap-3` flex row. It
* overlaps the gap (negative margins) so the panes' spacing is unchanged, and
* shows a hairline on hover / while dragging. Double-click resets the width.
*/
export function PaneResizeHandle({
label,
onMouseDown,
onDoubleClick,
active = false,
}: PaneResizeHandleProps) {
return (
<div
role="separator"
aria-orientation="vertical"
aria-label={label}
title={label}
data-pane-resize-handle
onMouseDown={onMouseDown}
onDoubleClick={onDoubleClick}
className="hidden md:block -mx-3 w-3 shrink-0 cursor-col-resize group relative z-10"
>
<div
className={cn(
"absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors",
active
? "bg-accent-brand"
: "bg-transparent group-hover:bg-accent-brand/60",
)}
/>
</div>
);
}
@@ -0,0 +1,148 @@
import React, { useState } from "react";
import { Document, Page, pdfjs } from "react-pdf";
import { AlertCircle, Download } from "lucide-react";
import { Button } from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import pdfjsWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl;
interface PdfPreviewProps {
content: string;
onDownload?: () => void;
onMediaDimensionsChange?: (dimensions: {
width: number;
height: number;
}) => void;
}
export function PdfPreview({
content,
onDownload,
onMediaDimensionsChange,
}: PdfPreviewProps) {
const { t } = useTranslation();
const [numPages, setNumPages] = useState<number | null>(null);
const [pageNumber, setPageNumber] = useState(1);
const [pdfScale, setPdfScale] = useState(1.2);
const [pdfError, setPdfError] = useState(false);
return (
<div className="h-full flex flex-col bg-background">
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageNumber(Math.max(1, pageNumber - 1))}
disabled={pageNumber <= 1}
>
{t("fileManager.previous")}
</Button>
<span className="text-sm text-foreground px-3 py-1 bg-background border border-border">
{t("fileManager.pageXOfY", {
current: pageNumber,
total: numPages || 0,
})}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
setPageNumber(Math.min(numPages || 1, pageNumber + 1))
}
disabled={!numPages || pageNumber >= numPages}
>
{t("fileManager.next")}
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPdfScale(Math.max(0.5, pdfScale - 0.2))}
>
{t("fileManager.zoomOut")}
</Button>
<span className="text-sm text-foreground px-3 py-1 bg-background border border-border min-w-[80px] text-center">
{Math.round(pdfScale * 100)}%
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPdfScale(Math.min(3.0, pdfScale + 0.2))}
>
{t("fileManager.zoomIn")}
</Button>
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-auto thin-scrollbar p-6 bg-surface">
<div className="flex justify-center">
{pdfError ? (
<div className="text-center text-muted-foreground p-8">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">
{t("fileManager.cannotLoadPdf")}
</h3>
<p className="text-sm mb-4">{t("fileManager.pdfLoadError")}</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
{t("fileManager.download")}
</Button>
)}
</div>
) : (
<Document
file={`data:application/pdf;base64,${content}`}
onLoadSuccess={({ numPages }) => {
setNumPages(numPages);
setPdfError(false);
onMediaDimensionsChange?.({
width: 800,
height: 600,
});
}}
onLoadError={(error) => {
console.error("PDF load error:", error);
setPdfError(true);
}}
loading={
<div className="text-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">
{t("fileManager.loadingPdf")}
</p>
</div>
}
>
<Page
pageNumber={pageNumber}
scale={pdfScale}
className="shadow-lg"
loading={
<div className="text-center p-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-xs text-muted-foreground">
{t("fileManager.loadingPage")}
</p>
</div>
}
/>
</Document>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,264 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
Button,
Input,
} from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Lock } from "lucide-react";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
permissions?: string;
owner?: string;
group?: string;
}
interface PermissionsDialogProps {
file: FileItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (file: FileItem, permissions: string) => Promise<void>;
}
const parsePermissions = (
perms: string,
): { owner: number; group: number; other: number } => {
if (!perms) {
return { owner: 0, group: 0, other: 0 };
}
if (/^\d{3,4}$/.test(perms)) {
const numStr = perms.slice(-3);
return {
owner: parseInt(numStr[0] || "0", 10),
group: parseInt(numStr[1] || "0", 10),
other: parseInt(numStr[2] || "0", 10),
};
}
const cleanPerms = perms.replace(/^-/, "").substring(0, 9);
const calcBits = (str: string): number => {
let value = 0;
if (str[0] === "r") value += 4;
if (str[1] === "w") value += 2;
if (str[2] === "x") value += 1;
return value;
};
return {
owner: calcBits(cleanPerms.substring(0, 3)),
group: calcBits(cleanPerms.substring(3, 6)),
other: calcBits(cleanPerms.substring(6, 9)),
};
};
const toNumeric = (owner: number, group: number, other: number): string => {
return `${owner}${group}${other}`;
};
export function PermissionsDialog({
file,
open,
onOpenChange,
onSave,
}: PermissionsDialogProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const initialPerms = parsePermissions(file?.permissions || "644");
const [ownerRead, setOwnerRead] = useState((initialPerms.owner & 4) !== 0);
const [ownerWrite, setOwnerWrite] = useState((initialPerms.owner & 2) !== 0);
const [ownerExecute, setOwnerExecute] = useState(
(initialPerms.owner & 1) !== 0,
);
const [groupRead, setGroupRead] = useState((initialPerms.group & 4) !== 0);
const [groupWrite, setGroupWrite] = useState((initialPerms.group & 2) !== 0);
const [groupExecute, setGroupExecute] = useState(
(initialPerms.group & 1) !== 0,
);
const [otherRead, setOtherRead] = useState((initialPerms.other & 4) !== 0);
const [otherWrite, setOtherWrite] = useState((initialPerms.other & 2) !== 0);
const [otherExecute, setOtherExecute] = useState(
(initialPerms.other & 1) !== 0,
);
useEffect(() => {
if (file) {
const perms = parsePermissions(file.permissions || "644");
setOwnerRead((perms.owner & 4) !== 0);
setOwnerWrite((perms.owner & 2) !== 0);
setOwnerExecute((perms.owner & 1) !== 0);
setGroupRead((perms.group & 4) !== 0);
setGroupWrite((perms.group & 2) !== 0);
setGroupExecute((perms.group & 1) !== 0);
setOtherRead((perms.other & 4) !== 0);
setOtherWrite((perms.other & 2) !== 0);
setOtherExecute((perms.other & 1) !== 0);
}
}, [file]);
const calculateOctal = (): string => {
const owner =
(ownerRead ? 4 : 0) + (ownerWrite ? 2 : 0) + (ownerExecute ? 1 : 0);
const group =
(groupRead ? 4 : 0) + (groupWrite ? 2 : 0) + (groupExecute ? 1 : 0);
const other =
(otherRead ? 4 : 0) + (otherWrite ? 2 : 0) + (otherExecute ? 1 : 0);
return toNumeric(owner, group, other);
};
const handleSave = async () => {
if (!file) return;
setLoading(true);
try {
const permissions = calculateOctal();
await onSave(file, permissions);
onOpenChange(false);
} catch (error) {
console.error("Failed to update permissions:", error);
} finally {
setLoading(false);
}
};
if (!file) return null;
const octal = calculateOctal();
const rows = [
{
label: `${t("fileManager.owner")}${file.owner ? ` (${file.owner})` : ""}`,
read: ownerRead,
setRead: setOwnerRead,
write: ownerWrite,
setWrite: setOwnerWrite,
execute: ownerExecute,
setExecute: setOwnerExecute,
},
{
label: `${t("fileManager.group")}${file.group ? ` (${file.group})` : ""}`,
read: groupRead,
setRead: setGroupRead,
write: groupWrite,
setWrite: setGroupWrite,
execute: groupExecute,
setExecute: setGroupExecute,
},
{
label: t("fileManager.others"),
read: otherRead,
setRead: setOtherRead,
write: otherWrite,
setWrite: setOtherWrite,
execute: otherExecute,
setExecute: setOtherExecute,
},
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg rounded-none border-border bg-card">
<DialogHeader>
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<Lock className="size-4 text-accent-brand" />
{t("fileManager.changePermissions")}
</DialogTitle>
<DialogDescription className="text-[10px] font-bold tracking-tight text-muted-foreground font-mono break-all">
{file.path}
</DialogDescription>
</DialogHeader>
<div className="py-3 flex flex-col gap-4">
<div className="border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_64px_64px_64px] bg-muted/50 border-b border-border">
<div className="px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground" />
{[
t("fileManager.read"),
t("fileManager.write"),
t("fileManager.execute"),
].map((h) => (
<div
key={h}
className="py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground text-center border-l border-border"
>
{h}
</div>
))}
</div>
{rows.map((row, i) => (
<div
key={i}
className={`grid grid-cols-[1fr_64px_64px_64px] ${i < rows.length - 1 ? "border-b border-border" : ""}`}
>
<div className="px-3 py-3 text-xs font-semibold">
{row.label}
</div>
{[
{ val: row.read, set: row.setRead },
{ val: row.write, set: row.setWrite },
{ val: row.execute, set: row.setExecute },
].map((perm, j) => (
<div
key={j}
className="flex items-center justify-center border-l border-border py-3"
>
<input
type="checkbox"
checked={perm.val}
onChange={(e) => perm.set(e.target.checked)}
className="accent-[var(--accent-brand)] size-4 cursor-pointer"
/>
</div>
))}
</div>
))}
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground shrink-0">
{t("fileManager.octal")}
</span>
<Input
value={octal}
readOnly
className="w-20 rounded-none bg-muted/50 border-border text-xs font-mono text-center h-8"
/>
<span className="text-[10px] text-muted-foreground font-mono">
{t("fileManager.currentPermissions")}: {file.permissions || "—"}
</span>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={loading}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
onClick={handleSave}
disabled={loading}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{loading ? t("common.saving") : t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,137 @@
import React from "react";
import { DraggableWindow } from "./DraggableWindow.tsx";
import type { TabHandle as TerminalHandle } from "@termix/plugin-sdk/frontend";
import { PluginComponent } from "@termix/plugin-sdk/ui";
import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { SSHHost } from "../host-types";
import { ExternalLink } from "lucide-react";
interface TerminalWindowProps {
windowId: string;
hostConfig: SSHHost;
initialPath?: string;
initialX?: number;
initialY?: number;
executeCommand?: string;
onPromoteToTab?: (path?: string) => void;
}
export function TerminalWindow({
windowId,
hostConfig,
initialPath,
initialX = 200,
initialY = 150,
executeCommand,
onPromoteToTab,
}: TerminalWindowProps) {
const { t } = useTranslation();
const { closeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const terminalRef = React.useRef<TerminalHandle | null>(null);
const resizeTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
return () => {
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
};
}, []);
const currentWindow = windows.find((w) => w.id === windowId);
if (!currentWindow) {
return null;
}
const handleClose = () => {
closeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = setTimeout(() => {
if (terminalRef.current?.fit) {
terminalRef.current.fit();
}
}, 150);
};
const handleFocus = () => {
focusWindow(windowId);
};
const handleResize = () => {
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = setTimeout(() => {
if (terminalRef.current?.fit) {
terminalRef.current.fit();
}
}, 100);
};
const handlePromoteToTab = () => {
onPromoteToTab?.(initialPath);
closeWindow(windowId);
};
const terminalTitle = executeCommand
? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand })
: initialPath
? t("terminal.terminalWithPath", {
host: hostConfig.name,
path: initialPath,
})
: t("terminal.terminalTitle", { host: hostConfig.name });
return (
<>
<DraggableWindow
title={terminalTitle}
initialX={initialX}
initialY={initialY}
initialWidth={800}
initialHeight={500}
minWidth={600}
minHeight={400}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
onResize={handleResize}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
titleActions={
onPromoteToTab ? (
<button
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
handlePromoteToTab();
}}
title={t("common.openInNewTab")}
>
<ExternalLink className="size-3.5" />
</button>
) : null
}
>
<PluginComponent
id="terminal.view"
handleRef={terminalRef}
hostConfig={hostConfig}
isVisible={!currentWindow.isMinimized}
initialPath={initialPath}
executeCommand={executeCommand}
onClose={handleClose}
/>
</DraggableWindow>
</>
);
}
@@ -0,0 +1,42 @@
import type { CSSProperties } from "react";
import { cn } from "@termix/plugin-sdk/ui";
export function TransferProgressBar({
value,
label,
stalled = false,
}: {
value?: number;
label: string;
stalled?: boolean;
}) {
const clamped = Math.min(100, Math.max(0, value ?? 0));
const indeterminate = value === undefined;
return (
<div
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={indeterminate ? undefined : clamped}
aria-busy={indeterminate}
data-stalled={stalled || undefined}
className="transfer-progress-track"
>
<div
className={cn(
"transfer-progress-fill",
indeterminate && "transfer-progress-indeterminate",
stalled && "transfer-progress-stalled",
)}
style={
indeterminate
? undefined
: ({ "--transfer-progress": clamped / 100 } as CSSProperties)
}
/>
</div>
);
}
@@ -0,0 +1,149 @@
import { Button } from "@termix/plugin-sdk/ui";
import {
formatTransferMbPerSec,
getTransferProgressPercent,
type TransferProgressResponse,
} from "../api/transfer-api";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import {
Archive,
ArchiveRestore,
ArrowRight,
Gauge,
RefreshCw,
ShieldCheck,
} from "lucide-react";
import { TransferProgressBar } from "./TransferProgressBar";
interface TransferProgressToastProps {
status: TransferProgressResponse;
liveMbPerSec?: number;
stalled?: boolean;
formatSize: (bytes?: number) => string;
onCancel?: () => void;
cancelling?: boolean;
}
export function TransferProgressToast({
status,
liveMbPerSec,
stalled = false,
formatSize,
onCancel,
cancelling = false,
}: TransferProgressToastProps) {
const { t } = useTranslation();
const percent = getTransferProgressPercent(status);
let title = t("transfer.progressTransferring");
if (status.phase === "reconnecting") {
title = t("transfer.progressReconnecting");
} else if (status.phase === "benchmarking") {
title = t("transfer.progressBenchmarking");
} else if (status.phase === "verifying") {
title = t("transfer.progressVerifying");
} else if (status.phase === "compressing") {
title = t("transfer.progressCompressing");
} else if (status.phase === "extracting") {
title = t("transfer.progressExtracting");
} else if (
status.method === "item_sftp" &&
status.totalItems !== undefined &&
status.itemsCompleted !== undefined
) {
title = t("transfer.progressTransferringItems", {
current: status.itemsCompleted,
total: status.totalItems,
});
}
const hasByteProgress =
status.bytesTransferred !== undefined &&
status.totalBytes !== undefined &&
status.totalBytes > 0;
const detailLeft = hasByteProgress
? t("transfer.progressBytes", {
transferred: formatSize(status.bytesTransferred),
total: formatSize(status.totalBytes),
})
: status.method === "item_sftp" &&
status.totalItems !== undefined &&
status.itemsCompleted !== undefined
? t("transfer.progressItems", {
current: status.itemsCompleted,
total: status.totalItems,
})
: null;
const liveRate =
status.phase === "reconnecting"
? undefined
: stalled
? t("transfer.progressStalled")
: liveMbPerSec !== undefined
? status.parallelSegmentCount && status.parallelSegmentCount > 1
? t("transfer.progressTotalSpeed", {
speed: formatTransferMbPerSec(liveMbPerSec),
lanes: status.parallelSegmentCount,
})
: formatTransferMbPerSec(liveMbPerSec)
: undefined;
const showIndeterminate =
status.phase === "reconnecting" || percent === undefined;
const PhaseIcon =
status.phase === "compressing"
? Archive
: status.phase === "benchmarking"
? Gauge
: status.phase === "verifying"
? ShieldCheck
: status.phase === "extracting"
? ArchiveRestore
: status.phase === "reconnecting"
? RefreshCw
: ArrowRight;
return (
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
<div className="flex items-start justify-between gap-2">
<p
key={status.phase}
className="motion-transfer-phase flex min-w-0 items-center gap-2 text-sm font-medium leading-tight"
>
<PhaseIcon className="size-3.5 shrink-0 text-accent-brand" />
<span className="truncate">{title}</span>
</p>
{onCancel && status.status === "running" && status.transferId && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 px-2 text-xs"
disabled={cancelling}
onClick={onCancel}
>
{cancelling
? t("transfer.progressCancelling")
: t("transfer.progressCancel")}
</Button>
)}
</div>
<TransferProgressBar
value={showIndeterminate ? undefined : percent}
label={title}
stalled={stalled}
/>
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
<span className="min-w-0 truncate">{detailLeft ?? ""}</span>
<span
className={`shrink-0 tabular-nums ${stalled ? "text-amber-500" : liveRate ? "font-medium text-foreground" : "invisible"}`}
aria-hidden={!liveRate}
>
{liveRate ?? "0 MB/s"}
</span>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,144 @@
/* eslint-disable react-refresh/only-export-components */
import React, { useState, useCallback, useRef } from "react";
export interface WindowInstance {
id: string;
title: string;
component: React.ReactNode | ((windowId: string) => React.ReactNode);
x: number;
y: number;
width: number;
height: number;
isMaximized: boolean;
isMinimized: boolean;
zIndex: number;
}
interface WindowManagerProps {
children?: React.ReactNode;
}
interface WindowManagerContextType {
windows: WindowInstance[];
openWindow: (window: Omit<WindowInstance, "id" | "zIndex">) => string;
closeWindow: (id: string) => void;
minimizeWindow: (id: string) => void;
maximizeWindow: (id: string) => void;
focusWindow: (id: string) => void;
updateWindow: (id: string, updates: Partial<WindowInstance>) => void;
}
const WindowManagerContext =
React.createContext<WindowManagerContextType | null>(null);
export function WindowManager({ children }: WindowManagerProps) {
const [windows, setWindows] = useState<WindowInstance[]>([]);
const nextZIndex = useRef(1000);
const windowCounter = useRef(0);
const openWindow = useCallback(
(windowData: Omit<WindowInstance, "id" | "zIndex">) => {
const id = `window-${++windowCounter.current}`;
const zIndex = ++nextZIndex.current;
const offset = (windows.length % 5) * 20;
let adjustedX = windowData.x + offset;
let adjustedY = windowData.y + offset;
const maxX = Math.max(0, window.innerWidth - windowData.width - 20);
const maxY = Math.max(0, window.innerHeight - windowData.height - 20);
adjustedX = Math.max(20, Math.min(adjustedX, maxX));
adjustedY = Math.max(20, Math.min(adjustedY, maxY));
const newWindow: WindowInstance = {
...windowData,
id,
zIndex,
x: adjustedX,
y: adjustedY,
};
setWindows((prev) => [...prev, newWindow]);
return id;
},
[windows.length],
);
const closeWindow = useCallback((id: string) => {
setWindows((prev) => prev.filter((w) => w.id !== id));
}, []);
const minimizeWindow = useCallback((id: string) => {
setWindows((prev) =>
prev.map((w) =>
w.id === id ? { ...w, isMinimized: !w.isMinimized } : w,
),
);
}, []);
const maximizeWindow = useCallback((id: string) => {
setWindows((prev) =>
prev.map((w) =>
w.id === id ? { ...w, isMaximized: !w.isMaximized } : w,
),
);
}, []);
const focusWindow = useCallback((id: string) => {
setWindows((prev) => {
const targetWindow = prev.find((w) => w.id === id);
if (!targetWindow) return prev;
const newZIndex = ++nextZIndex.current;
return prev.map((w) => (w.id === id ? { ...w, zIndex: newZIndex } : w));
});
}, []);
const updateWindow = useCallback(
(id: string, updates: Partial<WindowInstance>) => {
setWindows((prev) =>
prev.map((w) => (w.id === id ? { ...w, ...updates } : w)),
);
},
[],
);
const contextValue: WindowManagerContextType = {
windows,
openWindow,
closeWindow,
minimizeWindow,
maximizeWindow,
focusWindow,
updateWindow,
};
return (
<WindowManagerContext.Provider value={contextValue}>
{children}
<div
className="window-container absolute inset-0 pointer-events-none overflow-hidden"
style={{ zIndex: 1000 }}
>
<div className="relative w-full h-full pointer-events-none">
{windows.map((window) => (
<div key={window.id} className="pointer-events-auto">
{typeof window.component === "function"
? window.component(window.id)
: window.component}
</div>
))}
</div>
</div>
</WindowManagerContext.Provider>
);
}
export function useWindowManager() {
const context = React.useContext(WindowManagerContext);
if (!context) {
throw new Error("useWindowManager must be used within a WindowManager");
}
return context;
}
@@ -0,0 +1,38 @@
/** One mounted filesystem, as the host-metrics plugin reports it. */
export interface DiskFilesystem {
filesystem: string;
type: string;
mount: string;
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
usedBytes: number | null;
totalBytes: number | null;
availableBytes: number | null;
label?: string;
}
export interface HostDiskInfo {
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
mount: string | null;
filesystems?: DiskFilesystem[];
}
/** The filesystem the path lives on: the longest mount point that holds it. */
export function mountForPath(
filesystems: DiskFilesystem[],
path: string | null | undefined,
): DiskFilesystem | null {
if (!path) return null;
let best: DiskFilesystem | null = null;
for (const fs of filesystems) {
const mount = fs.mount.replace(/\/+$/, "") || "/";
const inside =
mount === "/" || path === mount || path.startsWith(`${mount}/`);
if (inside && (!best || mount.length > best.mount.length)) best = fs;
}
return best;
}
@@ -0,0 +1,37 @@
import type { FileItem, SSHHost } from "./host-types";
import type { LogEntry } from "@termix/plugin-sdk/ui";
export interface FileManagerProps {
initialHost?: SSHHost | null;
initialFilePath?: string;
initialPath?: string;
onClose?: () => void;
onOpenTerminalTab?: (path?: string) => void;
/** When false, pause keepalive while the tab stays mounted in the background. */
isVisible?: boolean;
}
export type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
export type SSHConnectionError = Error & {
connectionLogs?: ConnectionLogPayload[];
requires_totp?: boolean;
requires_browser_sign_in?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
code?: string;
label?: string;
status?: string;
reason?: "no_keyboard" | "auth_failed" | "timeout";
};
export interface CreateIntent {
id: string;
type: "file" | "directory";
defaultName: string;
currentName: string;
}
export type PendingSudoOperation =
{ type: "delete"; files: FileItem[] } | { type: "navigate"; path: string };
@@ -0,0 +1,35 @@
export function formatFileSize(bytes?: number): string {
if (bytes === undefined || bytes === null) return "-";
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
const formattedSize =
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
return `${formattedSize} ${units[unitIndex]}`;
}
export async function runWithConcurrency<T>(
items: T[],
limit: number,
task: (item: T) => Promise<void>,
): Promise<void> {
let nextIndex = 0;
const workerCount = Math.min(Math.max(1, limit), items.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < items.length) {
const item = items[nextIndex++];
await task(item);
}
}),
);
}
@@ -0,0 +1,118 @@
import {
markAdaptiveResourceUsed,
runAdaptiveBackgroundTask,
} from "@termix/plugin-sdk/ui";
export type FilePreviewKind =
| "image"
| "video"
| "audio"
| "markdown"
| "pdf"
| "text"
| "code"
| "unknown";
const extensions = {
image: new Set(["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp"]),
video: new Set(["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm"]),
audio: new Set(["mp3", "wav", "flac", "ogg", "aac", "m4a"]),
markdown: new Set(["md", "markdown", "mdown", "mkdn", "mdx"]),
pdf: new Set(["pdf"]),
text: new Set(["txt", "readme"]),
code: new Set([
"js",
"ts",
"jsx",
"tsx",
"py",
"java",
"cpp",
"c",
"cs",
"php",
"rb",
"go",
"rs",
"html",
"css",
"scss",
"less",
"json",
"xml",
"yaml",
"yml",
"toml",
"ini",
"conf",
"sh",
"bash",
"zsh",
"sql",
"vue",
"svelte",
]),
} satisfies Record<Exclude<FilePreviewKind, "unknown">, Set<string>>;
const extensionlessCodeFiles = new Set([
"dockerfile",
"makefile",
"rakefile",
"gemfile",
]);
export function resolveFilePreviewKind(filename: string): FilePreviewKind {
const lowerName = filename.toLowerCase();
if (extensionlessCodeFiles.has(lowerName)) return "code";
const extension = lowerName.split(".").pop() || "";
for (const [kind, knownExtensions] of Object.entries(extensions)) {
if (knownExtensions.has(extension)) return kind as FilePreviewKind;
}
return "unknown";
}
export const loadCodeEditor = () =>
import("./components/CodeEditor.tsx").then((module) => ({
default: module.CodeEditor,
}));
export const loadImagePreview = () =>
import("./components/ImagePreview.tsx").then((module) => ({
default: module.ImagePreview,
}));
export const loadMarkdownRenderer = () =>
import("./components/MarkdownRenderer.tsx").then((module) => ({
default: module.MarkdownRenderer,
}));
export const loadPdfPreview = () =>
import("./components/PdfPreview.tsx").then((module) => ({
default: module.PdfPreview,
}));
export const loadAudioPreview = () =>
import("./components/AudioPreview.tsx").then((module) => ({
default: module.AudioPreview,
}));
const previewLoaders: Partial<Record<FilePreviewKind, () => Promise<unknown>>> =
{
image: loadImagePreview,
audio: loadAudioPreview,
markdown: loadMarkdownRenderer,
pdf: loadPdfPreview,
text: loadCodeEditor,
code: loadCodeEditor,
unknown: loadCodeEditor,
};
export function preloadFilePreview(filename: string): void {
const kind = resolveFilePreviewKind(filename);
const loader = previewLoaders[kind];
if (loader) runAdaptiveBackgroundTask("module", `preview:${kind}`, loader);
}
export function markFilePreviewUsed(filename: string): void {
markAdaptiveResourceUsed(
"module",
`preview:${resolveFilePreviewKind(filename)}`,
);
}
@@ -0,0 +1,47 @@
import { FolderSearch } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { useHost } from "@termix/plugin-sdk/frontend";
import { WidgetTitle } from "@termix/plugin-sdk/ui";
import type {
FileManagerWidgetConfig,
WidgetComponentProps,
} from "./homepage.js";
import type { SSHHost } from "../host-types";
import { FileManager } from "../FileManager";
export function FileManagerWidget({
widget,
config,
}: WidgetComponentProps<FileManagerWidgetConfig>) {
const { t } = useTranslation();
const host = useHost(config.hostId || undefined);
if (!config.hostId) {
return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
<FolderSearch size={20} />
<span className="text-xs">{t("common.noHostConfigured")}</span>
</div>
);
}
if (!host) {
return (
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
{t("common.noHostConfigured")}
</div>
);
}
return (
<div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<FolderSearch size={11} />} />
<div
className="flex-1 overflow-hidden"
onMouseDown={(e) => e.stopPropagation()}
>
<FileManager initialHost={host as unknown as SSHHost} />
</div>
</div>
);
}
@@ -0,0 +1,42 @@
import { fileManagerHostSetting } from "../host-settings";
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
import { Select2 } from "@termix/plugin-sdk/ui";
import type {
FileManagerWidgetConfig,
WidgetEditFormProps,
} from "./homepage.js";
export function FileManagerWidgetEditForm({
config,
onChange,
}: WidgetEditFormProps<FileManagerWidgetConfig>) {
const { t } = useTranslation();
const { hosts } = useHosts();
const options = hosts.filter(
(host) =>
host.enableSsh !== false &&
fileManagerHostSetting(host, "enableFileManager", true),
);
return (
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">
{t("common.host")}
</label>
<Select2
value={config.hostId || ""}
onChange={(e) =>
onChange({ ...config, hostId: Number(e.target.value) })
}
className="h-8 text-xs border border-border bg-background px-2"
>
<option value="">{t("common.selectHost")}</option>
{options.map((host) => (
<option key={host.id} value={host.id}>
{host.name || host.ip}
</option>
))}
</Select2>
</div>
);
}
@@ -0,0 +1,36 @@
import type { ComponentType, ReactNode } from "react";
import type { ExtensionContribution } from "@termix/plugin-sdk/frontend";
/** The homepage's grid step, in pixels. */
export const GRID_SIZE = 30;
export interface FileManagerWidgetConfig {
hostId: number;
}
/** What the homepage hands a widget; only the fields this plugin reads. */
export interface WidgetComponentProps<C> {
widget: { title?: string | null };
config: C;
}
export interface WidgetEditFormProps<C> {
config: C;
onChange: (config: C) => void;
}
/** What app.registerExtension("homepage.widgets", ...) takes. */
export interface WidgetDefinition<C> extends ExtensionContribution {
id: string;
name: string;
description: string;
category: "links" | "info" | "system" | "monitoring";
icon: ReactNode;
defaultConfig: C;
defaultSize: { w: number; h: number };
minSize: { w: number; h: number };
components: {
view: ComponentType<WidgetComponentProps<C>>;
editForm?: ComponentType<WidgetEditFormProps<C>>;
};
}
@@ -0,0 +1,178 @@
import { useState, useCallback } from "react";
interface DragAndDropState {
isDragging: boolean;
dragCounter: number;
draggedFiles: File[];
}
interface UseDragAndDropProps {
onFilesDropped: (files: FileList) => void;
onItemsDropped?: (entries: FileSystemEntry[]) => void;
onError?: (error: string) => void;
maxFileSize?: number;
allowedTypes?: string[];
}
export function useDragAndDrop({
onFilesDropped,
onItemsDropped,
onError,
maxFileSize = 5120,
allowedTypes = [],
}: UseDragAndDropProps) {
const [state, setState] = useState<DragAndDropState>({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
const validateFiles = useCallback(
(files: FileList): string | null => {
const maxSizeBytes = maxFileSize * 1024 * 1024;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > maxSizeBytes) {
return `File "${file.name}" is too large. Maximum size is ${maxFileSize}MB.`;
}
if (allowedTypes.length > 0) {
const fileExt = file.name.split(".").pop()?.toLowerCase();
const mimeType = file.type.toLowerCase();
const isAllowed = allowedTypes.some((type) => {
if (type.startsWith(".")) {
return fileExt === type.slice(1);
}
if (type.includes("/")) {
return (
mimeType === type || mimeType.startsWith(type.replace("*", ""))
);
}
switch (type) {
case "image":
return mimeType.startsWith("image/");
case "video":
return mimeType.startsWith("video/");
case "audio":
return mimeType.startsWith("audio/");
case "text":
return mimeType.startsWith("text/");
default:
return false;
}
});
if (!isAllowed) {
return `File type "${file.type || "unknown"}" is not allowed.`;
}
}
}
return null;
},
[maxFileSize, allowedTypes],
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState((prev) => ({
...prev,
dragCounter: prev.dragCounter + 1,
}));
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setState((prev) => ({
...prev,
isDragging: true,
}));
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState((prev) => {
const newCounter = prev.dragCounter - 1;
return {
...prev,
dragCounter: newCounter,
isDragging: newCounter > 0,
};
});
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Read the entries before touching state. Updating state flushes a render
// and the browser clears dataTransfer once the drop handler unwinds, so
// anything read later comes back empty (Firefox is strictest here).
const entries: FileSystemEntry[] = [];
if (onItemsDropped && e.dataTransfer.items?.length > 0) {
for (const item of Array.from(e.dataTransfer.items)) {
const entry = item.webkitGetAsEntry?.();
if (entry) entries.push(entry);
}
}
const files = e.dataTransfer.files;
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
if (onItemsDropped && entries.some((entry) => entry.isDirectory)) {
onItemsDropped(entries);
return;
}
if (files.length === 0) {
return;
}
const validationError = validateFiles(files);
if (validationError) {
onError?.(validationError);
return;
}
onFilesDropped(files);
},
[validateFiles, onFilesDropped, onItemsDropped, onError],
);
const resetDragState = useCallback(() => {
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
}, []);
return {
isDragging: state.isDragging,
dragHandlers: {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
},
resetDragState,
};
}
@@ -0,0 +1,294 @@
import { useState, useCallback } from "react";
import { toast } from "sonner";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { downloadSSHFile } from "../api/ssh-file-operations-api";
import type { FileItem, SSHHost } from "../host-types";
interface DragToDesktopState {
isDragging: boolean;
isDownloading: boolean;
progress: number;
error: string | null;
}
interface UseDragToDesktopProps {
sshSessionId: string;
sshHost: SSHHost;
}
interface DragToDesktopOptions {
enableToast?: boolean;
onSuccess?: () => void;
onError?: (error: string) => void;
}
export function useDragToDesktop({ sshSessionId }: UseDragToDesktopProps) {
const { t } = useTranslation();
const [state, setState] = useState<DragToDesktopState>({
isDragging: false,
isDownloading: false,
progress: 0,
error: null,
});
const isElectron = () => {
return (
typeof window !== "undefined" &&
window.electronAPI &&
window.electronAPI.isElectron
);
};
const dragFileToDesktop = useCallback(
async (file: FileItem, options: DragToDesktopOptions = {}) => {
const { enableToast = true, onSuccess, onError } = options;
if (!isElectron()) {
const error =
"Drag to desktop feature is only available in desktop application";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
if (file.type !== "file") {
const error = "Only files can be dragged to desktop";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
try {
setState((prev) => ({
...prev,
isDownloading: true,
progress: 0,
error: null,
}));
const response = await downloadSSHFile(sshSessionId, file.path);
if (!response?.content) {
throw new Error("Unable to get file content");
}
setState((prev) => ({ ...prev, progress: 50 }));
const tempResult = await window.electronAPI.createTempFile({
fileName: file.name,
content: response.content,
encoding: "base64",
});
if (!tempResult.success) {
throw new Error(
tempResult.error || "Failed to create temporary file",
);
}
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
const dragResult = await window.electronAPI.startDragToDesktop({
tempId: tempResult.tempId,
fileName: file.name,
});
if (!dragResult.success) {
throw new Error(dragResult.error || "Failed to start dragging");
}
setState((prev) => ({ ...prev, progress: 100 }));
if (enableToast) {
toast.success(t("fileManager.dragToDesktop", { name: file.name }));
}
onSuccess?.();
setTimeout(async () => {
await window.electronAPI.cleanupTempFile(tempResult.tempId);
setState((prev) => ({
...prev,
isDragging: false,
isDownloading: false,
progress: 0,
}));
}, 10000);
return true;
} catch (error: unknown) {
console.error("Failed to drag to desktop:", error);
const err = error as { message?: string };
const errorMessage = err.message || "Drag failed";
setState((prev) => ({
...prev,
isDownloading: false,
isDragging: false,
progress: 0,
error: errorMessage,
}));
if (enableToast) {
toast.error(
t("fileManager.dragFailedDetail", { error: errorMessage }),
);
}
onError?.(errorMessage);
return false;
}
},
[sshSessionId],
);
const dragFilesToDesktop = useCallback(
async (files: FileItem[], options: DragToDesktopOptions = {}) => {
const { enableToast = true, onSuccess, onError } = options;
if (!isElectron()) {
const error =
"Drag to desktop feature is only available in desktop application";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
const fileList = files.filter((f) => f.type === "file");
if (fileList.length === 0) {
const error = "No files available for dragging";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
if (fileList.length === 1) {
return dragFileToDesktop(fileList[0], options);
}
try {
setState((prev) => ({
...prev,
isDownloading: true,
progress: 0,
error: null,
}));
const downloadPromises = fileList.map((file) =>
downloadSSHFile(sshSessionId, file.path),
);
const responses = await Promise.all(downloadPromises);
setState((prev) => ({ ...prev, progress: 40 }));
const folderName = `Files_${Date.now()}`;
const filesData = fileList.map((file, index) => ({
relativePath: file.name,
content: responses[index]?.content || "",
encoding: "base64" as const,
}));
const tempResult = await window.electronAPI.createTempFolder({
folderName,
files: filesData,
});
if (!tempResult.success) {
throw new Error(
tempResult.error || "Failed to create temporary folder",
);
}
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
const dragResult = await window.electronAPI.startDragToDesktop({
tempId: tempResult.tempId,
fileName: folderName,
});
if (!dragResult.success) {
throw new Error(dragResult.error || "Failed to start dragging");
}
setState((prev) => ({ ...prev, progress: 100 }));
if (enableToast) {
toast.success(
t("fileManager.dragFilesToDesktop", { count: fileList.length }),
);
}
onSuccess?.();
setTimeout(async () => {
await window.electronAPI.cleanupTempFile(tempResult.tempId);
setState((prev) => ({
...prev,
isDragging: false,
isDownloading: false,
progress: 0,
}));
}, 15000);
return true;
} catch (error: unknown) {
console.error("Failed to batch drag to desktop:", error);
const err = error as { message?: string };
const errorMessage = err.message || "Batch drag failed";
setState((prev) => ({
...prev,
isDownloading: false,
isDragging: false,
progress: 0,
error: errorMessage,
}));
if (enableToast) {
toast.error(
t("fileManager.dragFailedDetail", { error: errorMessage }),
);
}
onError?.(errorMessage);
return false;
}
},
[sshSessionId, dragFileToDesktop],
);
const dragFolderToDesktop = useCallback(
async (folder: FileItem, options: DragToDesktopOptions = {}) => {
const { enableToast = true, onError } = options;
if (!isElectron()) {
const error =
"Drag to desktop feature is only available in desktop application";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
if (folder.type !== "directory") {
const error = "Only folder types can be dragged";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
if (enableToast) {
toast.info(t("fileManager.folderDragUnsupported"));
}
return false;
},
[],
);
return {
...state,
isElectron: isElectron(),
dragFileToDesktop,
dragFilesToDesktop,
dragFolderToDesktop,
};
}
@@ -0,0 +1,304 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useState, useCallback, useRef } from "react";
import { toast } from "sonner";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { downloadSSHFile } from "../api/ssh-file-operations-api";
import type { FileItem, SSHHost } from "../host-types";
interface DragToSystemState {
isDragging: boolean;
isDownloading: boolean;
progress: number;
error: string | null;
}
interface UseDragToSystemProps {
sshSessionId: string;
sshHost: SSHHost;
}
interface DragToSystemOptions {
enableToast?: boolean;
onSuccess?: () => void;
onError?: (error: string) => void;
}
export function useDragToSystemDesktop({ sshSessionId }: UseDragToSystemProps) {
const { t } = useTranslation();
const [state, setState] = useState<DragToSystemState>({
isDragging: false,
isDownloading: false,
progress: 0,
error: null,
});
const dragDataRef = useRef<{
files: FileItem[];
options: DragToSystemOptions;
} | null>(null);
const saveLastDirectory = async (fileHandle: {
getParent?: () => Promise<unknown>;
}) => {
try {
if ("indexedDB" in window && fileHandle.getParent) {
const dirHandle = await fileHandle.getParent();
const request = indexedDB.open("termix-dirs", 1);
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction(["directories"], "readwrite");
const store = transaction.objectStore("directories");
store.put({ handle: dirHandle }, "lastSaveDir");
};
}
} catch (error) {
console.error("Drag operation failed:", error);
}
};
const isFileSystemAPISupported = () => {
return "showSaveFilePicker" in window;
};
const isDraggedOutsideWindow = (e: DragEvent) => {
const margin = 50;
return (
e.clientX < margin ||
e.clientX > window.innerWidth - margin ||
e.clientY < margin ||
e.clientY > window.innerHeight - margin
);
};
const createFileBlob = async (file: FileItem): Promise<Blob> => {
const response = await downloadSSHFile(sshSessionId, file.path);
if (!response?.content) {
throw new Error(`Unable to get content for file ${file.name}`);
}
const binaryString = atob(response.content);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes]);
};
const createZipBlob = async (files: FileItem[]): Promise<Blob> => {
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
for (const file of files) {
const blob = await createFileBlob(file);
zip.file(file.name, blob);
}
return await zip.generateAsync({ type: "blob" });
};
const fallbackDownload = (blob: Blob, fileName: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleDragToSystem = useCallback(
async (files: FileItem[], options: DragToSystemOptions = {}) => {
const { enableToast = true, onSuccess, onError } = options;
if (files.length === 0) {
const error = "No files available for dragging";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
const fileList = files.filter((f) => f.type === "file");
if (fileList.length === 0) {
const error = "Only files can be dragged to desktop";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
try {
setState((prev) => ({
...prev,
isDownloading: true,
progress: 0,
error: null,
}));
const fileName =
fileList.length === 1 ? fileList[0].name : `files_${Date.now()}.zip`;
let fileHandle: {
createWritable?: () => Promise<{
write: (data: Blob) => Promise<void>;
close: () => Promise<void>;
}>;
getParent?: () => Promise<unknown>;
} | null = null;
if (isFileSystemAPISupported()) {
try {
fileHandle = await (
window as Window & {
showSaveFilePicker?: (options: {
suggestedName: string;
startIn: string;
types: Array<{
description: string;
accept: Record<string, string[]>;
}>;
}) => Promise<{
createWritable?: () => Promise<{
write: (data: Blob) => Promise<void>;
close: () => Promise<void>;
}>;
getParent?: () => Promise<unknown>;
}>;
}
).showSaveFilePicker!({
suggestedName: fileName,
startIn: "desktop",
types: [
{
description: "Files",
accept: {
"*/*": [
".txt",
".jpg",
".png",
".pdf",
".zip",
".tar",
".gz",
],
},
},
],
});
} catch (error: unknown) {
const err = error as { name?: string };
if (err.name === "AbortError") {
setState((prev) => ({
...prev,
isDownloading: false,
progress: 0,
}));
return false;
}
throw error;
}
}
let blob: Blob;
if (fileList.length === 1) {
blob = await createFileBlob(fileList[0]);
setState((prev) => ({ ...prev, progress: 70 }));
} else {
blob = await createZipBlob(fileList);
setState((prev) => ({ ...prev, progress: 70 }));
}
setState((prev) => ({ ...prev, progress: 90 }));
if (fileHandle) {
await saveLastDirectory(fileHandle);
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
} else {
fallbackDownload(blob, fileName);
if (enableToast) {
toast.info(
"Due to browser limitations, file will be downloaded to default download directory",
);
}
}
setState((prev) => ({ ...prev, progress: 100 }));
if (enableToast) {
toast.success(
fileList.length === 1
? `${fileName} saved to specified location`
: `${fileList.length} files packaged and saved`,
);
}
onSuccess?.();
setTimeout(() => {
setState((prev) => ({ ...prev, isDownloading: false, progress: 0 }));
}, 1000);
return true;
} catch (error: unknown) {
const err = error as { message?: string };
const errorMessage = err.message || "Save failed";
setState((prev) => ({
...prev,
isDownloading: false,
progress: 0,
error: errorMessage,
}));
if (enableToast) {
toast.error(
t("fileManager.saveToDesktopFailed", { error: errorMessage }),
);
}
onError?.(errorMessage);
return false;
}
},
[sshSessionId],
);
const startDragToSystem = useCallback(
(files: FileItem[], options: DragToSystemOptions = {}) => {
dragDataRef.current = { files, options };
setState((prev) => ({ ...prev, isDragging: true, error: null }));
},
[],
);
const handleDragEnd = useCallback(
(e: DragEvent) => {
if (!dragDataRef.current) return;
const { files, options } = dragDataRef.current;
if (isDraggedOutsideWindow(e)) {
handleDragToSystem(files, options);
}
dragDataRef.current = null;
setState((prev) => ({ ...prev, isDragging: false }));
},
[handleDragToSystem],
);
const cancelDragToSystem = useCallback(() => {
dragDataRef.current = null;
setState((prev) => ({ ...prev, isDragging: false, error: null }));
}, []);
return {
...state,
isFileSystemAPISupported: isFileSystemAPISupported(),
startDragToSystem,
handleDragEnd,
cancelDragToSystem,
handleDragToSystem,
};
}
@@ -0,0 +1,92 @@
import { useState, useCallback } from "react";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
export function useFileSelection() {
const [selectedFiles, setSelectedFiles] = useState<FileItem[]>([]);
const selectFile = useCallback((file: FileItem, multiSelect = false) => {
if (multiSelect) {
setSelectedFiles((prev) => {
const isSelected = prev.some((f) => f.path === file.path);
if (isSelected) {
return prev.filter((f) => f.path !== file.path);
} else {
return [...prev, file];
}
});
} else {
setSelectedFiles([file]);
}
}, []);
const selectRange = useCallback(
(files: FileItem[], startFile: FileItem, endFile: FileItem) => {
const startIndex = files.findIndex((f) => f.path === startFile.path);
const endIndex = files.findIndex((f) => f.path === endFile.path);
if (startIndex !== -1 && endIndex !== -1) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const rangeFiles = files.slice(start, end + 1);
setSelectedFiles(rangeFiles);
}
},
[],
);
const selectAll = useCallback((files: FileItem[]) => {
setSelectedFiles([...files]);
}, []);
const clearSelection = useCallback(() => {
setSelectedFiles([]);
}, []);
const toggleSelection = useCallback((file: FileItem) => {
setSelectedFiles((prev) => {
const isSelected = prev.some((f) => f.path === file.path);
if (isSelected) {
return prev.filter((f) => f.path !== file.path);
} else {
return [...prev, file];
}
});
}, []);
const isSelected = useCallback(
(file: FileItem) => {
return selectedFiles.some((f) => f.path === file.path);
},
[selectedFiles],
);
const getSelectedCount = useCallback(() => {
return selectedFiles.length;
}, [selectedFiles]);
const setSelection = useCallback((files: FileItem[]) => {
setSelectedFiles(files);
}, []);
return {
selectedFiles,
selectFile,
selectRange,
selectAll,
clearSelection,
toggleSelection,
isSelected,
getSelectedCount,
setSelection,
};
}
@@ -0,0 +1,589 @@
import { useCallback, useEffect, useRef } from "react";
import { createElement } from "react";
import { toast } from "sonner";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { FileItem } from "../host-types";
import type { LocalWalkResult } from "@termix/plugin-sdk/frontend";
import { useSettings } from "@termix/plugin-sdk/frontend";
import {
cancelLocalTransfer,
createLocalTransferId,
downloadSessionFileToLocal,
uploadLocalFileToSession,
} from "../api/local-transfer-api";
import { createSSHFolder, listSSHFiles } from "../api/ssh-file-operations-api";
import {
ensureLocalDirectory,
getLocalHome,
localPathsExist,
walkLocalPaths,
} from "../lib/local-files";
import {
UnsafeLocalNameError,
buildLocalDestination,
clampTransferConcurrency,
DEFAULT_TRANSFER_CONCURRENCY,
runWithConcurrency,
takeLegacyTransferConcurrency,
joinRemotePath,
planRemoteDirectories,
remoteBaseName,
remoteDirForRelativePath,
} from "../local-transfer-utils.ts";
import {
LocalTransferProgressToast,
type LocalTransferBatchStatus,
} from "../components/LocalTransferProgressToast.tsx";
interface UseLocalTransfersOptions {
sshSessionId: string | null;
hostId?: number;
ensureSSHConnection: () => Promise<unknown>;
/** Called after uploads so the remote listing can refresh. */
onRemoteChanged: (remoteDir: string) => void;
/** Called after downloads so the local pane can refresh. */
onLocalChanged: (localDir: string) => void;
}
interface RemoteDownloadPlanEntry {
remotePath: string;
/** "/"-separated, includes the dragged root's own name. */
relativePath: string;
size?: number;
}
/**
* Asks whether existing local files may be replaced. Resolves `true` only on
* an explicit "Replace"; dismissing, auto-close and "Skip" all mean skip, so
* the batch can never overwrite silently.
*/
function askReplaceExisting(
t: ReturnType<typeof useTranslation>["t"],
count: number,
): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const settle = (value: boolean) => {
if (settled) return;
settled = true;
resolve(value);
};
toast.warning(t("fileManager.localReplaceExistingPrompt", { count }), {
duration: 15000,
action: {
label: t("fileManager.localReplace"),
onClick: () => settle(true),
},
cancel: {
label: t("fileManager.localSkipExisting"),
onClick: () => settle(false),
},
onDismiss: () => settle(false),
onAutoClose: () => settle(false),
});
});
}
/** One line a user can act on, taken from whatever the bridge or backend said. */
function describeTransferError(error: unknown): string | undefined {
const message =
error instanceof Error ? error.message : String(error ?? "").trim();
return message ? message.slice(0, 200) : undefined;
}
class TransferCancelledError extends Error {
constructor() {
super("Transfer cancelled");
this.name = "TransferCancelledError";
}
}
function createSpeedometer() {
let lastBytes = 0;
let lastTime = Date.now();
let mbPerSec: number | undefined;
return (bytesDone: number) => {
const now = Date.now();
const deltaMs = now - lastTime;
if (deltaMs >= 300) {
const deltaBytes = bytesDone - lastBytes;
if (deltaBytes >= 0) {
mbPerSec = (deltaBytes / deltaMs / 1024 / 1024) * 1000;
}
lastBytes = bytesDone;
lastTime = now;
}
return mbPerSec;
};
}
/**
* Orchestrates batched local<->remote transfers for the dual-pane file
* manager: expands folders, creates directory skeletons, streams files one by
* one through the Electron main process, and reports aggregate progress in a
* single toast with a cancel button.
*/
export function useLocalTransfers({
sshSessionId,
hostId,
ensureSSHConnection,
onRemoteChanged,
onLocalChanged,
}: UseLocalTransfersOptions) {
const { t } = useTranslation();
const batchCounter = useRef(0);
// "Simultaneous file transfers", the transferConcurrency user setting.
const userSettings = useSettings("user");
const concurrencyRef = useRef(DEFAULT_TRANSFER_CONCURRENCY);
concurrencyRef.current = clampTransferConcurrency(
userSettings.values.transferConcurrency ?? DEFAULT_TRANSFER_CONCURRENCY,
);
const { loaded: settingsLoaded, save: saveUserSettings } = userSettings;
useEffect(() => {
if (!settingsLoaded) return;
// A value this browser kept before 2.9.0 fills a setting still at its default.
const legacy = takeLegacyTransferConcurrency();
if (
legacy !== null &&
legacy !== concurrencyRef.current &&
concurrencyRef.current === DEFAULT_TRANSFER_CONCURRENCY
) {
concurrencyRef.current = legacy;
void saveUserSettings({ transferConcurrency: legacy }).catch(() => {});
}
}, [settingsLoaded, saveUserSettings]);
const runBatch = useCallback(
async (
direction: "upload" | "download",
totalFiles: number,
totalBytes: number,
work: (ctx: {
isCancelled: () => boolean;
/** Marks a transfer as in flight so Cancel can reach it. */
trackTransfer: (id: string, fileName: string) => void;
/** Progress of one in-flight transfer; bytes are summed across all. */
progress: (id: string, transferred: number) => void;
/** Transfer finished (success or failure); its size is now settled. */
settleTransfer: (id: string, size: number) => void;
}) => Promise<{ failed: string[]; reason?: string }>,
) => {
batchCounter.current += 1;
const toastId = `local-transfer-${batchCounter.current}`;
let cancelled = false;
let cancelling = false;
const inFlight = new Map<
string,
{ fileName: string; transferred: number }
>();
let settledBytes = 0;
let completedFiles = 0;
let lastFileName: string | undefined;
const speed = createSpeedometer();
const status: LocalTransferBatchStatus = {
direction,
totalFiles,
completedFiles: 0,
bytesDone: 0,
totalBytes,
};
const render = () => {
toast.loading(
createElement(LocalTransferProgressToast, {
status: { ...status, cancelling },
onCancel: () => {
cancelled = true;
cancelling = true;
render();
for (const id of inFlight.keys()) void cancelLocalTransfer(id);
},
}),
{ id: toastId, duration: Infinity },
);
};
render();
try {
const refresh = () => {
let bytesDone = settledBytes;
for (const entry of inFlight.values()) bytesDone += entry.transferred;
status.completedFiles = completedFiles;
status.bytesDone = bytesDone;
status.activeFiles = inFlight.size;
status.currentFileName =
lastFileName ?? inFlight.values().next().value?.fileName;
status.mbPerSec = speed(bytesDone);
render();
};
const { failed, reason } = await work({
isCancelled: () => cancelled,
trackTransfer: (id, fileName) => {
inFlight.set(id, { fileName, transferred: 0 });
lastFileName = fileName;
refresh();
},
progress: (id, transferred) => {
const entry = inFlight.get(id);
if (!entry) return;
entry.transferred = transferred;
lastFileName = entry.fileName;
refresh();
},
settleTransfer: (id, size) => {
inFlight.delete(id);
settledBytes += size;
completedFiles += 1;
refresh();
},
});
toast.dismiss(toastId);
if (cancelled) {
toast.info(t("fileManager.localTransferCancelled"));
return;
}
const key = direction === "upload" ? "Upload" : "Download";
if (failed.length === 0) {
toast.success(
t(`fileManager.local${key}Complete`, { count: totalFiles }),
);
} else if (failed.length === totalFiles) {
// Every file failed the same way more often than not (auth,
// permissions, a dead session); say why instead of just "failed".
toast.error(
t(`fileManager.local${key}Failed`),
reason ? { description: reason } : undefined,
);
} else {
toast.warning(
t(`fileManager.local${key}Partial`, {
done: totalFiles - failed.length,
failed: failed.length,
}),
reason ? { description: reason } : undefined,
);
}
} catch (error) {
toast.dismiss(toastId);
if (error instanceof TransferCancelledError || cancelled) {
toast.info(t("fileManager.localTransferCancelled"));
return;
}
const message =
error instanceof Error ? error.message : String(error ?? "");
toast.error(
direction === "upload"
? t("fileManager.localUploadFailed")
: t("fileManager.localDownloadFailed"),
message ? { description: message } : undefined,
);
console.error(`Local ${direction} batch failed:`, error);
}
},
[t],
);
/** Uploads local files/folders (by absolute path) into a remote directory. */
const uploadLocalPaths = useCallback(
async (localPaths: string[], remoteDir: string) => {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
if (localPaths.length === 0) return;
let plan: LocalWalkResult;
try {
plan = await walkLocalPaths(localPaths);
} catch (error) {
toast.error(
t("fileManager.localUploadFailed"),
error instanceof Error ? { description: error.message } : undefined,
);
return;
}
if (plan.files.length === 0 && plan.emptyDirs.length === 0) {
toast.info(t("fileManager.localNothingToTransfer"));
return;
}
const sessionId = sshSessionId;
await runBatch(
"upload",
plan.files.length,
plan.totalBytes,
async ({ isCancelled, trackTransfer, progress, settleTransfer }) => {
await ensureSSHConnection();
const dirs = planRemoteDirectories(
plan.files.map((f) => f.relativePath),
plan.emptyDirs,
);
for (const dir of dirs) {
if (isCancelled()) throw new TransferCancelledError();
const parent = dir.includes("/")
? joinRemotePath(remoteDir, dir.slice(0, dir.lastIndexOf("/")))
: remoteDir;
const name = dir.split("/").pop()!;
// The backend tolerates existing directories (#1442), so a
// failure here is real and should stop the batch up front.
await createSSHFolder(sessionId, parent, name, hostId);
}
// Files go up several at a time (see "Simultaneous File Transfers"
// in the profile settings); directories were created above, so
// order between files no longer matters.
const failed: string[] = [];
let reason: string | undefined;
await runWithConcurrency(
plan.files,
concurrencyRef.current,
async (file) => {
const fileName = file.relativePath.split("/").pop()!;
const targetDir = remoteDirForRelativePath(
remoteDir,
file.relativePath,
);
const transferId = createLocalTransferId("local-upload");
trackTransfer(transferId, fileName);
try {
await uploadLocalFileToSession({
sessionId,
remoteDir: targetDir,
localPath: file.localPath,
fileName,
hostId,
transferId,
onProgress: ({ transferred }) =>
progress(transferId, transferred),
});
} catch (error) {
if (!isCancelled()) {
failed.push(file.relativePath);
reason ??= describeTransferError(error);
console.error(`Failed to upload ${file.localPath}:`, error);
}
} finally {
settleTransfer(transferId, file.size);
}
},
isCancelled,
);
return { failed, reason };
},
);
onRemoteChanged(remoteDir);
},
[sshSessionId, hostId, ensureSSHConnection, onRemoteChanged, runBatch, t],
);
/** Downloads remote files/folders into a local directory. */
const downloadRemoteItems = useCallback(
async (items: FileItem[], localDir: string) => {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
if (items.length === 0) return;
const sessionId = sshSessionId;
const { separator } = await getLocalHome();
// Every remote name is validated for the local platform and the result
// must stay inside `localDir`; anything else is reported and skipped.
const toLocalPath = (relativePath: string) =>
buildLocalDestination(localDir, relativePath, separator);
// Expand directories into a flat file plan first so the toast can show
// a real total. Listing is the only part that goes through the
// renderer's normal API path.
const plan: RemoteDownloadPlanEntry[] = [];
const emptyDirs: string[] = [];
const expandTaskId = `local-download-expand-${Date.now()}`;
toast.loading(t("fileManager.localPreparingDownload"), {
id: expandTaskId,
duration: Infinity,
});
try {
await ensureSSHConnection();
const walkRemote = async (remotePath: string, relDir: string) => {
const { files } = await listSSHFiles(sessionId, remotePath, {
force: true,
});
if (files.length === 0) {
emptyDirs.push(relDir);
return;
}
for (const child of files) {
const rel = `${relDir}/${child.name}`;
if (child.type === "directory") {
await walkRemote(child.path, rel);
} else if (child.type === "file" || child.type === "link") {
plan.push({
remotePath: child.path,
relativePath: rel,
size: child.size,
});
}
}
};
for (const item of items) {
const name = item.name || remoteBaseName(item.path);
if (item.type === "directory") {
await walkRemote(item.path, name);
} else {
plan.push({
remotePath: item.path,
relativePath: name,
size: item.size,
});
}
}
} catch (error) {
toast.dismiss(expandTaskId);
toast.error(
t("fileManager.localDownloadFailed"),
error instanceof Error ? { description: error.message } : undefined,
);
return;
}
toast.dismiss(expandTaskId);
if (plan.length === 0 && emptyDirs.length === 0) {
toast.info(t("fileManager.localNothingToTransfer"));
return;
}
// Drop anything whose remote name cannot become a safe local path
// (separators, traversal, Windows-invalid characters). These never
// reach the filesystem; the user is told how many were skipped.
const unsafe: string[] = [];
const safeDest = (relativePath: string): string | null => {
try {
return toLocalPath(relativePath);
} catch (error) {
if (error instanceof UnsafeLocalNameError) {
unsafe.push(relativePath);
return null;
}
throw error;
}
};
const plannedDirs = emptyDirs
.map((dir) => ({ dir, dest: safeDest(dir) }))
.filter((d): d is { dir: string; dest: string } => d.dest !== null);
const plannedFiles = plan
.map((entry) => ({ entry, dest: safeDest(entry.relativePath) }))
.filter(
(p): p is { entry: RemoteDownloadPlanEntry; dest: string } =>
p.dest !== null,
);
if (unsafe.length > 0) {
toast.error(
t("fileManager.localUnsafeNamesSkipped", { count: unsafe.length }),
{ description: unsafe.slice(0, 3).join(", ") },
);
console.warn("Skipped remote items with unsafe local names:", unsafe);
}
if (plannedFiles.length === 0 && plannedDirs.length === 0) {
return;
}
// Collision policy: never replace silently. Find destinations that
// already exist and let the user choose Replace or Skip for the batch.
const destinations = plannedFiles.map((p) => p.dest);
let existing: Set<string>;
try {
existing = new Set(await localPathsExist(destinations));
} catch (error) {
toast.error(
t("fileManager.localDownloadFailed"),
error instanceof Error ? { description: error.message } : undefined,
);
return;
}
let overwriteExisting = false;
if (existing.size > 0) {
overwriteExisting = await askReplaceExisting(t, existing.size);
}
const skipped = overwriteExisting
? []
: plannedFiles.filter(({ dest }) => existing.has(dest));
const work = overwriteExisting
? plannedFiles
: plannedFiles.filter(({ dest }) => !existing.has(dest));
if (skipped.length > 0) {
toast.info(
t("fileManager.localSkippedExisting", { count: skipped.length }),
);
}
if (work.length === 0 && plannedDirs.length === 0) {
onLocalChanged(localDir);
return;
}
const workBytes = work.reduce((sum, w) => sum + (w.entry.size ?? 0), 0);
await runBatch(
"download",
work.length,
workBytes,
async ({ isCancelled, trackTransfer, progress, settleTransfer }) => {
for (const { dest } of plannedDirs) {
if (isCancelled()) throw new TransferCancelledError();
await ensureLocalDirectory(dest, localDir);
}
const failed: string[] = [];
let reason: string | undefined;
await runWithConcurrency(
work,
concurrencyRef.current,
async ({ entry, dest }) => {
const fileName = entry.relativePath.split("/").pop()!;
const transferId = createLocalTransferId("local-download");
trackTransfer(transferId, fileName);
try {
await downloadSessionFileToLocal({
sessionId,
remotePath: entry.remotePath,
destPath: dest,
rootPath: localDir,
expectedSize: entry.size,
overwrite: overwriteExisting && existing.has(dest),
transferId,
onProgress: ({ transferred }) =>
progress(transferId, transferred),
});
} catch (error) {
if (!isCancelled()) {
failed.push(entry.relativePath);
reason ??= describeTransferError(error);
console.error(
`Failed to download ${entry.remotePath}:`,
error,
);
}
} finally {
settleTransfer(transferId, entry.size ?? 0);
}
},
isCancelled,
);
return { failed, reason };
},
);
onLocalChanged(localDir);
},
[sshSessionId, ensureSSHConnection, onLocalChanged, runBatch, t],
);
return { uploadLocalPaths, downloadRemoteItems };
}
@@ -0,0 +1,106 @@
import { useCallback, useRef, useState } from "react";
import type React from "react";
interface UsePaneWidthOptions {
/** localStorage key the width is remembered under. */
storageKey: string;
defaultWidth: number;
minWidth: number;
/**
* Upper bound as a fraction of the containing row's width (measured at
* drag start from `containerRef`), so a pane can never swallow the layout.
*/
maxFraction?: number;
containerRef?: React.RefObject<HTMLElement | null>;
}
function readStoredWidth(
storageKey: string,
defaultWidth: number,
minWidth: number,
): number {
try {
const saved = Number(localStorage.getItem(storageKey));
return Number.isFinite(saved) && saved >= minWidth ? saved : defaultWidth;
} catch {
return defaultWidth;
}
}
/**
* A persisted, drag-resizable pane width. `startResize` goes on the
* mousedown of a vertical drag handle sitting on the pane's right edge;
* `resetWidth` (e.g. on double-click) restores the default.
*/
export function usePaneWidth({
storageKey,
defaultWidth,
minWidth,
maxFraction = 0.65,
containerRef,
}: UsePaneWidthOptions) {
const [width, setWidth] = useState<number>(() =>
readStoredWidth(storageKey, defaultWidth, minWidth),
);
const [isResizing, setIsResizing] = useState(false);
const widthRef = useRef(width);
widthRef.current = width;
const persist = useCallback(
(value: number) => {
try {
localStorage.setItem(storageKey, String(value));
} catch {
// storage unavailable
}
},
[storageKey],
);
const startResize = useCallback(
(event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = widthRef.current;
const rowWidth =
containerRef?.current?.getBoundingClientRect().width ?? 0;
const maxWidth = Math.max(
minWidth,
rowWidth ? rowWidth * maxFraction : Number.POSITIVE_INFINITY,
);
let latest = startWidth;
setIsResizing(true);
const onMove = (e: MouseEvent) => {
latest = Math.round(
Math.min(
maxWidth,
Math.max(minWidth, startWidth + (e.clientX - startX)),
),
);
if (latest !== widthRef.current) setWidth(latest);
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
setIsResizing(false);
persist(latest);
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[containerRef, maxFraction, minWidth, persist],
);
const resetWidth = useCallback(() => {
setWidth(defaultWidth);
persist(defaultWidth);
}, [defaultWidth, persist]);
return { width, isResizing, startResize, resetWidth };
}
@@ -0,0 +1,239 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type React from "react";
export interface ResizableColumnSpec {
/** Stable key, also used for persistence. */
key: string;
/** i18n key for the column's label (used by the show/hide menu). */
labelKey: string;
defaultWidth: number;
minWidth?: number;
maxWidth?: number;
}
interface UseResizableColumnsOptions {
/** localStorage key; widths are remembered per pane. */
storageKey: string;
/**
* Columns, in visual order, that follow the file name. Saved widths are
* preferred maxima; metadata shrinks first when the pane is narrow.
*/
columns: ResizableColumnSpec[];
}
const DEFAULT_MIN = 48;
const DEFAULT_MAX = 600;
function readStoredWidths(
storageKey: string,
columns: ResizableColumnSpec[],
): Record<string, number> {
const widths: Record<string, number> = {};
for (const column of columns) widths[column.key] = column.defaultWidth;
try {
const raw = localStorage.getItem(storageKey);
if (!raw) return widths;
const parsed = JSON.parse(raw) as Record<string, unknown>;
for (const column of columns) {
const value = parsed?.[column.key];
if (typeof value === "number" && Number.isFinite(value)) {
widths[column.key] = clamp(value, column);
}
}
} catch {
// storage unavailable or corrupt: fall back to defaults
}
return widths;
}
function readHiddenKeys(
storageKey: string,
columns: ResizableColumnSpec[],
): Set<string> {
try {
const raw = localStorage.getItem(`${storageKey}:hidden`);
if (!raw) return new Set();
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return new Set();
const known = new Set(columns.map((c) => c.key));
return new Set(
parsed.filter((k): k is string => typeof k === "string" && known.has(k)),
);
} catch {
return new Set();
}
}
function clamp(value: number, column: ResizableColumnSpec): number {
const min = column.minWidth ?? DEFAULT_MIN;
const max = column.maxWidth ?? DEFAULT_MAX;
return Math.round(Math.min(max, Math.max(min, value)));
}
/**
* Column widths for a list view whose first column is flexible and whose
* remaining columns are user-resizable by dragging the boundary at
* the left edge of each header cell. Double-clicking a handle resets that
* column to its default. Widths persist in localStorage.
*/
export function useResizableColumns({
storageKey,
columns,
}: UseResizableColumnsOptions) {
const [widths, setWidths] = useState<Record<string, number>>(() =>
readStoredWidths(storageKey, columns),
);
const [hiddenKeys, setHiddenKeys] = useState<Set<string>>(() =>
readHiddenKeys(storageKey, columns),
);
const [resizingKey, setResizingKey] = useState<string | null>(null);
const widthsRef = useRef(widths);
widthsRef.current = widths;
// Re-read if the pane is re-keyed (e.g. a different storage key).
useEffect(() => {
setWidths(readStoredWidths(storageKey, columns));
setHiddenKeys(readHiddenKeys(storageKey, columns));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storageKey]);
const isVisible = useCallback(
(key: string) => !hiddenKeys.has(key),
[hiddenKeys],
);
const visibleColumns = useMemo(
() => columns.filter((c) => !hiddenKeys.has(c.key)),
[columns, hiddenKeys],
);
const toggleColumn = useCallback(
(key: string) => {
setHiddenKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
try {
localStorage.setItem(
`${storageKey}:hidden`,
JSON.stringify(Array.from(next)),
);
} catch {
// storage unavailable
}
return next;
});
},
[storageKey],
);
const persist = useCallback(
(next: Record<string, number>) => {
try {
localStorage.setItem(storageKey, JSON.stringify(next));
} catch {
// storage unavailable
}
},
[storageKey],
);
const gridTemplateColumns = useMemo(
() =>
[
"minmax(min(240px, 50%), 1fr)",
...visibleColumns.map((c) => `minmax(0, ${widths[c.key]}px)`),
].join(" "),
[visibleColumns, widths],
);
const startResize = useCallback(
(key: string, event: React.MouseEvent) => {
const column = columns.find((c) => c.key === key);
if (!column) return;
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = widthsRef.current[key];
setResizingKey(key);
const onMove = (e: MouseEvent) => {
// The handle sits on the column's left edge, so dragging right
// narrows the column and dragging left widens it.
const next = clamp(startWidth - (e.clientX - startX), column);
if (next !== widthsRef.current[key]) {
setWidths((prev) => ({ ...prev, [key]: next }));
}
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
setResizingKey(null);
persist(widthsRef.current);
// The browser dispatches a click to the common ancestor of the
// mousedown/mouseup targets, which here is the sortable header cell.
// Swallow that one click so finishing a drag never toggles the sort.
const swallow = (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
};
document.addEventListener("click", swallow, { capture: true });
setTimeout(
() =>
document.removeEventListener("click", swallow, { capture: true }),
0,
);
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
},
[columns, persist],
);
const resetColumn = useCallback(
(key: string) => {
const column = columns.find((c) => c.key === key);
if (!column) return;
setWidths((prev) => {
const next = { ...prev, [key]: column.defaultWidth };
persist(next);
return next;
});
},
[columns, persist],
);
const getHandleProps = useCallback(
(key: string) => ({
onMouseDown: (event: React.MouseEvent) => startResize(key, event),
onDoubleClick: (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
resetColumn(key);
},
onClick: (event: React.MouseEvent) => event.stopPropagation(),
"data-resizing": resizingKey === key ? "true" : undefined,
}),
[startResize, resetColumn, resizingKey],
);
return {
widths,
gridTemplateColumns,
getHandleProps,
resizingKey,
columns,
visibleColumns,
isVisible,
toggleColumn,
};
}
export type ResizableColumns = ReturnType<typeof useResizableColumns>;
@@ -0,0 +1,14 @@
/** One of this plugin's host settings, from the host payload. */
export function fileManagerHostSetting<T>(
host: object | null | undefined,
key: "enableFileManager" | "defaultPath" | "scpLegacy",
fallback: T,
): T {
const settings = (
host as { pluginSettings?: Record<string, Record<string, unknown>> } | null
)?.pluginSettings;
const value = settings?.["file-manager"]?.[key];
return value === undefined || value === null || value === ""
? fallback
: (value as T);
}
@@ -0,0 +1,59 @@
import type { HostSshOptions } from "@termix/plugin-sdk/frontend";
/** A host as the shell hands it to the file manager. */
export interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
folder: string;
tags: string[];
pin: boolean;
authType: string;
password?: string;
key?: string;
keyPassword?: string;
keyType?: string;
sudoPassword?: string;
forceKeyboardInteractive?: boolean;
credentialId?: number;
overrideCredentialUsername?: boolean;
userId?: string;
jumpHosts?: Array<{ hostId: number }>;
sshOptions?: HostSshOptions | null;
notes?: string;
useSocks5?: boolean;
socks5Host?: string;
socks5Port?: number;
socks5Username?: string;
socks5Password?: string;
socks5ProxyChain?: unknown[];
connectionType?: string;
enableSsh?: boolean;
syncId?: string | null;
createdAt: string;
updatedAt: string;
connectionOrigin?: "local" | "remote" | null;
instanceId?: string;
isShared?: boolean;
permissionLevel?: "connect" | "view" | "edit" | "manage";
/** Enabled plugins' host-scope settings, keyed by plugin id. */
pluginSettings?: Record<string, Record<string, unknown>>;
}
export interface FileItem {
name: string;
path: string;
isPinned?: boolean;
type: "file" | "directory" | "link";
sshSessionId?: string;
size?: number;
modified?: string;
modifiedTimestamp?: number;
permissions?: string;
owner?: string;
group?: string;
linkTarget?: string;
executable?: boolean;
}
+173
View File
@@ -0,0 +1,173 @@
import { fileManagerHostSetting } from "./host-settings";
import type { ComponentType } from "react";
import { FolderOpen, FolderSearch, ArrowLeftRight } from "lucide-react";
import {
invokeAction,
type PluginHostRecord,
type StandaloneViewProps,
type TabProps,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import {
GRID_SIZE,
type FileManagerWidgetConfig,
type WidgetDefinition,
} from "./homepage/homepage.js";
import { FileManager } from "./FileManager.tsx";
import FileManagerApp from "./FileManagerApp.tsx";
import { SftpTransferTab } from "./SftpTransferTab.tsx";
import { FileManagerWidget } from "./homepage/FileManagerWidget.tsx";
import { FileManagerWidgetEditForm } from "./homepage/FileManagerWidgetEditForm.tsx";
import { startTransferMonitor } from "./TransferMonitor.tsx";
import { setFileManagerApp } from "./api/client";
function FilesTab({ tab, host, sshHost, isVisible }: TabProps) {
const data = tab.data as
{ initialFilePath?: string; initialPath?: string } | undefined;
return (
<FileManager
initialHost={sshHost as never}
initialFilePath={data?.initialFilePath}
initialPath={data?.initialPath}
isVisible={isVisible}
onOpenTerminalTab={
host
? (path) => void invokeAction("terminal.open", host, { path })
: undefined
}
/>
);
}
function FilesStandalone({ hostId, params }: StandaloneViewProps) {
return (
<FileManagerApp
hostId={hostId}
initialPath={params.get("path") ?? undefined}
/>
);
}
function SftpTab() {
return <SftpTransferTab />;
}
function openHostAction(
app: TermixApp,
host: PluginHostRecord | null,
path: string | undefined,
): void {
if (!host) return;
app.tabs.openTab(host, "files", { data: { initialPath: path } });
}
function openEditorAction(
app: TermixApp,
host: PluginHostRecord | null,
filePath: string,
): void {
if (!host) return;
app.tabs.openTab(host, "files", { data: { initialFilePath: filePath } });
}
/** Where this plugin serves the streaming routes the desktop app calls. */
const TRANSFER_API_PATH = "/plugin-api/file-manager";
export function activate(app: TermixApp): void {
setFileManagerApp(app);
if (typeof window !== "undefined") {
void window.electronAPI?.localTransfer?.setApiPath?.(TRANSFER_API_PATH);
}
app.onDispose(() => setFileManagerApp(null));
app.registerTab("files", FilesTab as unknown as ComponentType<TabProps>, {
icon: FolderSearch,
titleKey: "nav.files",
requiresHost: true,
noHostMessageKey: "fileManager.noHostSelected",
persistent: true,
standalone: FilesStandalone,
// The pre-conversion `?view=file-manager` link keeps resolving.
standaloneViews: ["file-manager"],
// The activity log still records "file_manager" (matching the backend's
// stored string), so recent-activity entries resolve to this tab.
activityTypes: ["file_manager"],
});
app.registerTab("sftp", SftpTab as unknown as ComponentType<TabProps>, {
icon: ArrowLeftRight,
titleKey: "nav.sftp",
hostless: true,
inLayouts: false,
});
app.registerRailItem({
id: "sftp",
icon: ArrowLeftRight,
titleKey: "nav.sftp",
kind: "tab",
after: "ssh-tools",
permission: "use",
});
app.registerHostAction({
id: "files",
titleKey: "nav.files",
icon: FolderSearch,
kind: "open",
order: 20,
tabType: "files",
copyUrlView: "file-manager",
quickConnect: true,
when: (host) =>
!!host.enableSsh &&
fileManagerHostSetting(host, "enableFileManager", true),
});
app.registerExtension("homepage.widgets", {
id: "file_manager_widget",
name: "File Manager",
description: "Embedded SFTP file manager for a configured host",
category: "system",
icon: <FolderSearch size={14} />,
defaultConfig: { hostId: 0 },
defaultSize: { w: GRID_SIZE * 20, h: GRID_SIZE * 14 },
minSize: { w: GRID_SIZE * 10, h: GRID_SIZE * 8 },
components: {
view: FileManagerWidget,
editForm: FileManagerWidgetEditForm,
},
} satisfies WidgetDefinition<FileManagerWidgetConfig>);
const openHost = ((host: PluginHostRecord | null, path?: string) =>
openHostAction(app, host, path)) as never;
app.registerAction("files.openHost", openHost);
// "Open File Manager" in the tab bar's menu, for a terminal tab. The
// terminal answers with its working directory and opens the files tab.
app.registerAction("file-manager.openFromTab", ((handle: unknown) =>
(
handle as { openFileManager?: () => void } | null
)?.openFileManager?.()) as never);
app.registerSlotContribution("tab.menu", {
actionId: "file-manager.openFromTab",
titleKey: "nav.openFileManager",
icon: FolderOpen,
kind: "button",
when: (context) =>
typeof (context.handle as { openFileManager?: unknown } | null)
?.openFileManager === "function",
});
app.registerAction("files.openEditor", ((
host: PluginHostRecord | null,
filePath: string,
) => openEditorAction(app, host, filePath)) as never);
app.registerSlotContribution("onboarding.features", {
actionId: "file-manager.feature",
titleKey: "onboarding.feature_files",
descriptionKey: "onboarding.feature_files_desc",
icon: FolderSearch as ComponentType<{ className?: string }>,
});
const stopTransferMonitor = startTransferMonitor(app.t);
app.onDispose(stopTransferMonitor);
}
@@ -0,0 +1,14 @@
/**
* Extract a stable, human-readable message from an unknown thrown value.
*
* A thrown value is not guaranteed to be an `Error` — libraries may throw
* strings or plain objects, which carry no useful `.message`. When that
* happens the caller gets `fallback` instead, so error paths never surface a
* raw `[object Object]` in logs or UI.
*/
export function getErrorMessage(
error: unknown,
fallback = "Unknown error",
): string {
return error instanceof Error ? error.message : fallback;
}
@@ -0,0 +1,38 @@
import type { FileItem } from "../host-types";
import { createKeyedRequestCache } from "./keyed-request-cache";
export interface FileContentResult {
content: string;
path: string;
encoding?: "base64" | "utf8";
}
const cache = createKeyedRequestCache<FileContentResult>(30_000, 24);
const keyFor = (sessionId: string, path: string) => `${sessionId}\0${path}`;
export function getCachedFileContent(
sessionId: string,
path: string,
loader: () => Promise<FileContentResult>,
force = false,
): Promise<FileContentResult> {
return cache.get(keyFor(sessionId, path), loader, { force });
}
export function invalidateCachedFileContent(
sessionId: string,
path: string,
): void {
cache.invalidate(keyFor(sessionId, path));
}
export function shouldPrefetchFileContent(
file: FileItem,
maxPrefetchBytes: number,
): boolean {
return (
file.type === "file" &&
typeof file.size === "number" &&
file.size <= maxPrefetchBytes
);
}
@@ -0,0 +1,43 @@
import type { FileItem } from "../host-types";
import { createKeyedRequestCache } from "./keyed-request-cache";
export interface FileListResult {
files: FileItem[];
path: string;
}
const cache = createKeyedRequestCache<FileListResult>(5_000, 150);
const keyFor = (sessionId: string, path: string) => `${sessionId}\0${path}`;
export function getCachedFileList(
sessionId: string,
path: string,
loader: () => Promise<FileListResult>,
force = false,
): Promise<FileListResult> {
return cache.get(keyFor(sessionId, path), loader, { force });
}
export function peekCachedFileList(
sessionId: string,
path: string,
maxAgeMs = 30_000,
): FileListResult | null {
return cache.peek(keyFor(sessionId, path), maxAgeMs);
}
export function invalidateCachedFileList(
sessionId: string,
path: string,
): void {
cache.invalidate(keyFor(sessionId, path));
}
export function updateCachedFileList(
sessionId: string,
path: string,
files: FileItem[],
): void {
cache.set(keyFor(sessionId, path), { files, path });
}
@@ -0,0 +1,22 @@
/**
* The shape an axios rejection actually has at the call sites that inspect it.
* A `catch` binding is `unknown`, and every one of these sites was reaching
* into it directly; this gives them one narrowing helper instead.
*/
export interface HttpLikeError {
message?: string;
code?: string;
response?: {
status?: number;
data?: {
error?: string;
message?: string;
code?: string;
fileNotFound?: boolean;
};
};
}
export function asHttpError(error: unknown): HttpLikeError {
return (error ?? {}) as HttpLikeError;
}
@@ -0,0 +1,82 @@
export interface KeyedRequestCache<T> {
get(
key: string,
loader: () => Promise<T>,
options?: { force?: boolean },
): Promise<T>;
peek(key: string, maxAgeMs?: number): T | null;
set(key: string, value: T): void;
invalidate(key?: string): void;
}
/** Bounded per-key cache with in-flight request coalescing. */
export function createKeyedRequestCache<T>(
ttlMs: number,
maxEntries = 100,
): KeyedRequestCache<T> {
const values = new Map<string, { value: T; storedAt: number }>();
const inflight = new Map<string, Promise<T>>();
const versions = new Map<string, number>();
const store = (key: string, value: T) => {
values.delete(key);
values.set(key, { value, storedAt: Date.now() });
while (values.size > Math.max(1, maxEntries)) {
const oldest = values.keys().next().value;
if (oldest === undefined) break;
values.delete(oldest);
}
};
return {
get(key, loader, options = {}) {
const cached = values.get(key);
if (!options.force && cached && Date.now() - cached.storedAt < ttlMs) {
return Promise.resolve(cached.value);
}
const pending = inflight.get(key);
if (pending) return pending;
const version = versions.get(key) ?? 0;
const request = loader()
.then((value) => {
if ((versions.get(key) ?? 0) === version) store(key, value);
return value;
})
.finally(() => {
if (inflight.get(key) === request) inflight.delete(key);
});
inflight.set(key, request);
return request;
},
peek(key, maxAgeMs = Number.POSITIVE_INFINITY) {
const cached = values.get(key);
if (!cached || Date.now() - cached.storedAt > maxAgeMs) return null;
values.delete(key);
values.set(key, cached);
return cached.value;
},
set(key, value) {
versions.set(key, (versions.get(key) ?? 0) + 1);
inflight.delete(key);
store(key, value);
},
invalidate(key) {
if (key === undefined) {
for (const activeKey of inflight.keys()) {
versions.set(activeKey, (versions.get(activeKey) ?? 0) + 1);
}
values.clear();
inflight.clear();
} else {
versions.set(key, (versions.get(key) ?? 0) + 1);
values.delete(key);
inflight.delete(key);
}
},
};
}
@@ -0,0 +1,105 @@
// Thin, typed access to the desktop app's local filesystem bridge
// (electron/local-files.cjs via preload.js). Everything here resolves to
// "unavailable" outside Electron so callers can feature-detect cheaply.
import type {
LocalDirectoryListing,
LocalFsHomeInfo,
LocalFsResult,
LocalTrashResult,
LocalWalkResult,
} from "@termix/plugin-sdk/frontend";
import { isElectron } from "@termix/plugin-sdk/ui";
export function isLocalFileBrowserAvailable(): boolean {
if (!isElectron()) return false;
const api = window.electronAPI;
return !!api?.localFs && !!api?.localTransfer;
}
function requireLocalFs() {
const api = window.electronAPI?.localFs;
if (!api) {
throw new Error("Local file access is only available in the desktop app");
}
return api;
}
function unwrap<T>(result: LocalFsResult<T>): T {
if (!result || result.success !== true) {
const message =
result && "error" in result && result.error
? result.error
: "Local file operation failed";
const error = new Error(message) as Error & { code?: string };
if (result && "code" in result) error.code = result.code;
throw error;
}
return result;
}
export async function getLocalHome(): Promise<LocalFsHomeInfo> {
return unwrap(await requireLocalFs().home());
}
export async function listLocalDirectory(
dirPath: string,
): Promise<LocalDirectoryListing> {
return unwrap(await requireLocalFs().list(dirPath));
}
export async function createLocalFolder(
parentPath: string,
name: string,
): Promise<string> {
return unwrap(await requireLocalFs().mkdir(parentPath, name)).path;
}
export async function createLocalFile(
parentPath: string,
name: string,
): Promise<string> {
return unwrap(await requireLocalFs().createFile(parentPath, name)).path;
}
export async function renameLocalEntry(
oldPath: string,
newName: string,
): Promise<string> {
return unwrap(await requireLocalFs().rename(oldPath, newName)).path;
}
/** Moves entries to the OS trash; resolves with per-path failures, if any. */
export async function trashLocalPaths(
paths: string[],
): Promise<LocalTrashResult> {
return unwrap(await requireLocalFs().trash(paths));
}
/** Subset of `paths` that already exist on disk. */
export async function localPathsExist(paths: string[]): Promise<string[]> {
if (paths.length === 0) return [];
return unwrap(await requireLocalFs().exists(paths)).existing;
}
/** Creates `dirPath` (and parents). With `rootPath`, refuses anything outside it. */
export async function ensureLocalDirectory(
dirPath: string,
rootPath?: string,
): Promise<string> {
return unwrap(await requireLocalFs().ensureDir(dirPath, rootPath)).path;
}
export async function walkLocalPaths(
paths: string[],
): Promise<LocalWalkResult> {
return unwrap(await requireLocalFs().walk(paths));
}
export async function revealLocalPath(targetPath: string): Promise<void> {
unwrap(await requireLocalFs().reveal(targetPath));
}
export async function openLocalPath(targetPath: string): Promise<void> {
unwrap(await requireLocalFs().open(targetPath));
}
@@ -0,0 +1,407 @@
// Pure helpers shared by the dual-pane (local <-> remote) file manager code.
// Kept free of React/DOM so they can be unit tested directly.
import type { LocalFileEntry } from "@termix/plugin-sdk/frontend";
/** Custom MIME type carried by drags that originate in the local pane. */
export const LOCAL_FILES_DRAG_MIME = "application/x-termix-local-files";
/**
* Custom MIME type the remote grid adds to its internal drags so other panes
* can recognise them during dragenter/dragover (when payloads are unreadable).
*/
export const REMOTE_FILES_DRAG_MIME = "application/x-termix-remote-files";
export interface LocalFilesDragPayload {
type: "local_files";
paths: string[];
}
export interface InternalFilesDragPayload {
type: "internal_files";
files: string[];
}
export function serializeLocalFilesDragPayload(paths: string[]): string {
const payload: LocalFilesDragPayload = { type: "local_files", paths };
return JSON.stringify(payload);
}
export function parseLocalFilesDragPayload(
raw: string | null | undefined,
): string[] | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<LocalFilesDragPayload>;
if (parsed?.type !== "local_files" || !Array.isArray(parsed.paths)) {
return null;
}
const paths = parsed.paths.filter(
(p): p is string => typeof p === "string" && p.length > 0,
);
return paths.length > 0 ? paths : null;
} catch {
return null;
}
}
/** Parses the payload the remote grid puts on `text/plain` for internal drags. */
export function parseInternalFilesDragPayload(
raw: string | null | undefined,
): string[] | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<InternalFilesDragPayload>;
if (parsed?.type !== "internal_files" || !Array.isArray(parsed.files)) {
return null;
}
const files = parsed.files.filter(
(p): p is string => typeof p === "string" && p.length > 0,
);
return files.length > 0 ? files : null;
} catch {
return null;
}
}
/** True while a drag that started in the local pane is over the element. */
export function isLocalFilesDrag(
dataTransfer: Pick<DataTransfer, "types"> | null | undefined,
): boolean {
if (!dataTransfer) return false;
return Array.from(dataTransfer.types ?? []).includes(LOCAL_FILES_DRAG_MIME);
}
/**
* Marks a drag that starts on remote rows. Within the remote grid a drop is a
* move; onto the local pane it is a copy (a download). The source has to
* allow both, because Chromium refuses a drop whose dropEffect is not in
* effectAllowed without firing any event at all -- the drop just silently
* does nothing.
*/
export function beginRemoteFilesDrag(
dataTransfer: Pick<DataTransfer, "setData" | "effectAllowed">,
remotePaths: string[],
): void {
const payload: InternalFilesDragPayload = {
type: "internal_files",
files: remotePaths,
};
dataTransfer.setData("text/plain", JSON.stringify(payload));
// Lets sibling panes recognise this drag before the payload is readable.
dataTransfer.setData(REMOTE_FILES_DRAG_MIME, "1");
dataTransfer.effectAllowed = "copyMove";
}
/** True while a drag that started in the remote grid is over the element. */
export function isRemoteFilesDrag(
dataTransfer: Pick<DataTransfer, "types"> | null | undefined,
): boolean {
if (!dataTransfer) return false;
return Array.from(dataTransfer.types ?? []).includes(REMOTE_FILES_DRAG_MIME);
}
/** Joins a POSIX remote directory with one or more path segments. */
export function joinRemotePath(base: string, ...segments: string[]): string {
let out = base || "/";
for (const segment of segments) {
const clean = segment.replace(/^\/+|\/+$/g, "");
if (!clean) continue;
out = out.endsWith("/") ? `${out}${clean}` : `${out}/${clean}`;
}
return out;
}
/** Joins a local directory and a file name using the platform separator. */
export function joinLocalPath(
base: string,
name: string,
separator: string,
): string {
const sep = separator || "/";
const trimmedBase = base.endsWith(sep) ? base.slice(0, -sep.length) : base;
// Root on POSIX is "/" which trims to ""; keep the separator in that case.
return `${trimmedBase}${sep}${name}`;
}
/** Thrown when a remote name cannot be used as a local path component. */
export class UnsafeLocalNameError extends Error {
readonly code = "EINVAL";
constructor(
readonly name: string,
readonly reason: string,
) {
super(`Unsafe file name "${name}": ${reason}`);
}
}
// Windows refuses these device names in any directory, with any extension.
const WINDOWS_RESERVED_NAMES =
/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.[^.]*)?$/i;
/**
* Validates one remote path component for use as a single local path
* segment on the destination platform (`separator` "\\" means Windows).
*
* A POSIX file name may legally contain "\\", ":" or end in a dot, all of
* which Windows treats as separators, drive prefixes or strips — so a remote
* name like "..\\outside.txt" would otherwise be normalised out of the
* selected download folder. Anything that is not a plain name is rejected;
* traversal ("..") and separators are rejected on every platform.
*/
export function assertSafeLocalComponent(
name: string,
separator: string,
): string {
if (name === "" || name === "." || name === "..") {
throw new UnsafeLocalNameError(name, "empty or traversal component");
}
if (name.includes("/") || name.includes("\0")) {
throw new UnsafeLocalNameError(name, "contains a path separator or NUL");
}
if (separator === "\\") {
if (/[\\:*?"<>|]/.test(name)) {
throw new UnsafeLocalNameError(
name,
"contains a character Windows does not allow in file names",
);
}
if (/[\u0001-\u001f]/.test(name)) {
throw new UnsafeLocalNameError(name, "contains control characters");
}
if (/[. ]$/.test(name)) {
throw new UnsafeLocalNameError(
name,
"Windows strips trailing dots and spaces",
);
}
if (WINDOWS_RESERVED_NAMES.test(name)) {
throw new UnsafeLocalNameError(name, "reserved device name on Windows");
}
}
return name;
}
function samePathPrefix(a: string, b: string, separator: string): boolean {
return separator === "\\" ? a.toLowerCase() === b.toLowerCase() : a === b;
}
/**
* Builds the local destination for a "/"-separated relative remote path
* under `localDir`, validating every component for the destination platform
* and asserting the result stays strictly inside `localDir`.
*/
export function buildLocalDestination(
localDir: string,
relativePath: string,
separator: string,
): string {
const sep = separator || "/";
const parts = relativePath.split("/").filter((p) => p !== "");
if (parts.length === 0) {
throw new UnsafeLocalNameError(relativePath, "empty path");
}
const dest = parts.reduce(
(acc, part) => joinLocalPath(acc, assertSafeLocalComponent(part, sep), sep),
localDir,
);
const root = localDir.endsWith(sep) ? localDir : `${localDir}${sep}`;
if (
!samePathPrefix(dest.slice(0, root.length), root, sep) ||
dest.length <= root.length
) {
throw new UnsafeLocalNameError(
relativePath,
"destination escapes the selected folder",
);
}
return dest;
}
export function remoteBaseName(remotePath: string): string {
const trimmed = remotePath.replace(/\/+$/, "");
return trimmed.split("/").pop() || trimmed || "/";
}
/**
* Given the "/"-separated relative paths of files being uploaded (each
* including its top-level root name) plus any empty directories, returns the
* set of directories that must exist on the remote, shallowest first, so each
* parent is created before its children.
*/
export function planRemoteDirectories(
fileRelativePaths: string[],
emptyDirs: string[] = [],
): string[] {
const dirs = new Set<string>();
for (const relativePath of fileRelativePaths) {
const parts = relativePath.split("/").filter(Boolean);
for (let i = 1; i < parts.length; i++) {
dirs.add(parts.slice(0, i).join("/"));
}
}
for (const dir of emptyDirs) {
const parts = dir.split("/").filter(Boolean);
for (let i = 1; i <= parts.length; i++) {
dirs.add(parts.slice(0, i).join("/"));
}
}
return Array.from(dirs).sort(
(a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b),
);
}
/** Remote directory a file with the given relative path should land in. */
export function remoteDirForRelativePath(
base: string,
relativePath: string,
): string {
const idx = relativePath.lastIndexOf("/");
if (idx <= 0) return base;
return joinRemotePath(base, relativePath.slice(0, idx));
}
/** Short "Kind" column label, in the spirit of Finder / Termius. */
export function describeLocalKind(
entry: Pick<LocalFileEntry, "name" | "type">,
): string {
if (entry.type === "directory") return "folder";
if (entry.type === "link") return "link";
const dot = entry.name.lastIndexOf(".");
if (dot > 0 && dot < entry.name.length - 1) {
return entry.name.slice(dot + 1).toLowerCase();
}
return "file";
}
export type LocalSortField = "name" | "modified" | "size" | "kind";
export function sortLocalEntries(
entries: LocalFileEntry[],
field: LocalSortField,
order: "asc" | "desc",
): LocalFileEntry[] {
const dir = order === "asc" ? 1 : -1;
const collator = new Intl.Collator(undefined, {
numeric: true,
sensitivity: "base",
});
return [...entries].sort((a, b) => {
// Folders always group first, matching the remote grid.
const aDir = a.type === "directory" ? 0 : 1;
const bDir = b.type === "directory" ? 0 : 1;
if (aDir !== bDir) return aDir - bDir;
let cmp = 0;
switch (field) {
case "modified":
cmp = (a.modifiedTimestamp ?? 0) - (b.modifiedTimestamp ?? 0);
break;
case "size":
cmp = a.size - b.size;
break;
case "kind":
cmp = collator.compare(describeLocalKind(a), describeLocalKind(b));
break;
default:
cmp = 0;
}
if (cmp === 0) cmp = collator.compare(a.name, b.name);
return cmp * dir;
});
}
const MONTH_ABBREVIATIONS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
/**
* Formats a local mtime the same way the backend formats remote entries
* (`formatMtime` in src/backend/hosts/file-manager/utils.ts, i.e. `ls -l`
* style): `Sep 11 16:25` for the last six months, `Sep 11 2025` before
* that — so both panes read alike.
*/
export function formatLocalModified(
timestamp?: number,
now: Date = new Date(),
): string {
if (!timestamp) return "--";
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return "--";
const month = MONTH_ABBREVIATIONS[date.getMonth()];
const day = date.getDate().toString().padStart(2, " ");
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000);
if (date > sixMonthsAgo) {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${month} ${day} ${hours}:${minutes}`;
}
return `${month} ${day} ${date.getFullYear()}`;
}
// ---------------------------------------------------------------------------
// Parallel transfers
/** Where a browser kept the preference before it became a user setting. */
export const TRANSFER_CONCURRENCY_STORAGE_KEY =
"termix:file-manager:transfer-concurrency";
export const DEFAULT_TRANSFER_CONCURRENCY = 4;
export const MAX_TRANSFER_CONCURRENCY = 8;
export function clampTransferConcurrency(value: unknown): number {
const n = Math.floor(Number(value));
if (!Number.isFinite(n)) return DEFAULT_TRANSFER_CONCURRENCY;
return Math.min(MAX_TRANSFER_CONCURRENCY, Math.max(1, n));
}
/**
* The value this browser kept before 2.9.0, removed as it is read so it only
* moves into the transferConcurrency user setting once. Null when none.
*/
export function takeLegacyTransferConcurrency(): number | null {
try {
const raw = localStorage.getItem(TRANSFER_CONCURRENCY_STORAGE_KEY);
if (raw === null) return null;
localStorage.removeItem(TRANSFER_CONCURRENCY_STORAGE_KEY);
return clampTransferConcurrency(raw);
} catch {
return null;
}
}
/**
* Runs `worker` over `items` with at most `limit` in flight, preserving the
* original order of dispatch. Stops dispatching new items once `shouldStop()`
* returns true; items already in flight run to completion (the caller
* cancels those through their own transfer ids). Never rejects because of a
* single item: worker errors are the worker's business.
*/
export async function runWithConcurrency<T>(
items: readonly T[],
limit: number,
worker: (item: T, index: number) => Promise<void>,
shouldStop: () => boolean = () => false,
): Promise<void> {
const size = Math.max(1, Math.min(limit, items.length));
let next = 0;
const lanes = Array.from({ length: size }, async () => {
while (next < items.length && !shouldStop()) {
const index = next++;
await worker(items[index], index);
}
});
await Promise.all(lanes);
}
@@ -0,0 +1,53 @@
import type { FileItem } from "./host-types";
export function childPath(parent: string, name: string): string {
return parent === "/" ? `/${name}` : `${parent.replace(/\/$/, "")}/${name}`;
}
export function addOptimisticItem(
files: FileItem[],
parent: string,
name: string,
type: "file" | "directory",
now = Date.now(),
): FileItem[] {
const path = childPath(parent, name);
if (files.some((file) => file.path === path)) return files;
return [
...files,
{
name,
path,
type,
size: type === "file" ? 0 : undefined,
modified: new Date(now).toISOString(),
modifiedTimestamp: now,
},
];
}
export function removePaths(files: FileItem[], paths: Set<string>): FileItem[] {
return files.filter((file) => !paths.has(file.path));
}
export function restoreItems(
files: FileItem[],
restored: FileItem[],
): FileItem[] {
const existing = new Set(files.map((file) => file.path));
return [...files, ...restored.filter((file) => !existing.has(file.path))];
}
export function renameOptimisticItem(
files: FileItem[],
originalPath: string,
newName: string,
): FileItem[] {
const slash = originalPath.lastIndexOf("/");
const parent = slash <= 0 ? "/" : originalPath.slice(0, slash);
return files.map((file) =>
file.path === originalPath
? { ...file, name: newName, path: childPath(parent, newName) }
: file,
);
}
@@ -0,0 +1,26 @@
export function normalizeRemoteDir(remotePath: string): string {
const trimmed = remotePath.trim().replace(/\\/g, "/");
if (!trimmed) return "/";
const collapsed = trimmed.replace(/\/+/g, "/");
const absolute = collapsed.startsWith("/") ? collapsed : `/${collapsed}`;
if (absolute === "/") return "/";
return absolute.replace(/\/+$/, "");
}
export function joinRemotePath(basePath: string, childPath: string): string {
const base = normalizeRemoteDir(basePath);
const child = childPath.replace(/\\/g, "/").replace(/^\/+/, "");
if (!child) return base;
return base === "/" ? `/${child}` : `${base}/${child}`;
}
export function hasSameHostTransferConflict(
sourcePaths: string[],
destinationDir: string,
): boolean {
const dest = normalizeRemoteDir(destinationDir);
return sourcePaths.some((sourcePath) => {
const source = normalizeRemoteDir(sourcePath);
return dest === source || dest.startsWith(`${source}/`);
});
}
Loaded 100 of 4405 files, more files were not shown because too many files have changed in this diff. Show more