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,126 @@
import { once } from "node:events";
import { PassThrough } from "node:stream";
import type { Express, Request, Response } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { describe, expect, it, vi } from "vitest";
import { registerFileDownloadRoutes } from "../../src/backend/download-routes.js";
import type { SSHSession } from "../../src/backend/session.js";
function setup(remote: object, owned = true) {
const routes = new Map<
string,
(req: Request, res: Response) => Promise<unknown>
>();
registerFileDownloadRoutes(
{
post: (path, handler) => routes.set(path, handler),
} as unknown as Express,
{
ctx: {
currentActor: () => "user",
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
} as unknown as PluginContext,
sshSessions: { s: { isConnected: true, sftp: remote } as SSHSession },
scheduleSessionCleanup: vi.fn(),
verifySessionOwnership: () => owned,
},
);
const headers = new Map<string, string>();
const res = Object.assign(new PassThrough(), {
headersSent: false,
setHeader: (name: string, value: string) => headers.set(name, value),
removeHeader: (name: string) => headers.delete(name),
status: vi.fn().mockReturnThis(),
json: vi.fn(),
});
const start = () =>
routes.get("/downloadFileStream")!(
{ body: { sessionId: "s", path: "/file" } } as Request,
res as unknown as Response,
);
return { res, start, headers };
}
function source() {
return {
stat: vi.fn((_path, cb) => cb(undefined, { size: 5, isFile: () => true })),
open: vi.fn((_path, _flags, _mode, cb) =>
cb(undefined, Buffer.from("handle")),
),
read: vi.fn((_handle, buffer, offset, _length, _position, cb) => {
Buffer.from("hello").copy(buffer, offset);
cb(undefined, 5);
}),
close: vi.fn((_handle, cb) => cb()),
};
}
describe("stream download route", () => {
it("streams the file content with its advertised length", async () => {
const remote = source();
const { res, start, headers } = setup(remote);
await start();
const chunks: Buffer[] = [];
for await (const chunk of res) chunks.push(chunk);
expect(Buffer.concat(chunks).toString()).toBe("hello");
expect(headers.get("Content-Length")).toBe("5");
expect(remote.close).toHaveBeenCalledOnce();
});
it("does not open a file after the HTTP client disconnected during stat", async () => {
const remote = source();
let finishStat!: () => void;
remote.stat.mockImplementation((_path, cb) => {
finishStat = () => cb(undefined, { size: 5, isFile: () => true });
});
const { res, start } = setup(remote);
const started = start();
await Promise.resolve();
res.destroy();
finishStat();
await started;
expect(remote.open).not.toHaveBeenCalled();
});
it("closes the SFTP handle after HTTP disconnect during a read", async () => {
const remote = source();
let finishRead!: () => void;
remote.read.mockImplementation(
(_h, buffer, offset, length, position, cb) => {
finishRead = () => cb(undefined, 0);
},
);
const { res, start } = setup(remote);
await start();
await new Promise<void>((resolve) => setImmediate(resolve));
res.destroy();
await once(res, "close");
finishRead();
await vi.waitFor(() => expect(remote.close).toHaveBeenCalledOnce());
expect(remote.read).toHaveBeenCalledOnce();
});
it("removes the file length before returning an early stream error as JSON", async () => {
const remote = source();
remote.open.mockImplementation((_p, _f, _m, cb) =>
cb(new Error("permission denied"), undefined),
);
const { res, start, headers } = setup(remote);
await start();
await vi.waitFor(() => expect(res.status).toHaveBeenCalledWith(500));
expect(headers.has("Content-Length")).toBe(false);
expect(res.json).toHaveBeenCalledWith({
error: "Download failed: permission denied",
});
res.destroy();
});
it("refuses another user's SSH session before reading a file", async () => {
const remote = source();
const { res, start } = setup(remote, false);
await start();
expect(res.status).toHaveBeenCalledWith(403);
expect(remote.stat).not.toHaveBeenCalled();
res.destroy();
});
});
@@ -0,0 +1,74 @@
import { once } from "node:events";
import type { AddressInfo } from "node:net";
import { Client, Server, utils, type SFTPWrapper } from "ssh2";
import { expect, it } from "vitest";
import { createDownloadStream } from "../../src/backend/download-stream.js";
it("overlaps real SFTP reads while retaining the serial stream's byte content", async () => {
const data = Buffer.alloc(1024 * 1024 + 71);
for (let i = 0; i < data.length; i++) data[i] = (i * 7 + (i >>> 10)) % 256;
let pending = 0;
let peak = 0;
let closed = 0;
const server = new Server(
{
hostKeys: [utils.generateKeyPairSync("rsa", { bits: 2048 }).private],
},
(client) => {
client.on("authentication", (ctx) => ctx.accept());
client.on("ready", () =>
client.on("session", (accept) => {
accept().on("sftp", (acceptSftp) => {
const sftp = acceptSftp();
sftp.on("OPEN", (id) => sftp.handle(id, Buffer.from("file")));
sftp.on("READ", (id, _handle, offset, length) => {
peak = Math.max(peak, ++pending);
setTimeout(() => {
pending--;
if (offset >= data.length) sftp.status(id, 1);
else sftp.data(id, data.subarray(offset, offset + length));
}, 10);
});
sftp.on("CLOSE", (id) => {
closed++;
sftp.status(id, 0);
});
});
}),
);
},
);
const client = new Client();
try {
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve),
);
client.connect({
host: "127.0.0.1",
port: (server.address() as AddressInfo).port,
username: "test",
});
await once(client, "ready");
const sftp = await new Promise<SFTPWrapper>((resolve, reject) =>
client.sftp((err, channel) => (err ? reject(err) : resolve(channel))),
);
const serial: Buffer[] = [];
for await (const chunk of sftp.createReadStream("/file"))
serial.push(chunk);
expect(Buffer.concat(serial)).toEqual(data);
expect(peak).toBe(1);
peak = 0;
const pipelined: Buffer[] = [];
for await (const chunk of createDownloadStream(sftp, "/file", data.length))
pipelined.push(chunk);
expect(Buffer.concat(pipelined)).toEqual(data);
expect(peak).toBeGreaterThan(1);
expect(peak).toBeLessThanOrEqual(8);
expect(closed).toBe(2);
} finally {
const closedClient = once(client, "close");
client.destroy();
await closedClient;
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}, 15000);
@@ -0,0 +1,133 @@
import { once } from "node:events";
import { setImmediate as nextTurn } from "node:timers/promises";
import type { SFTPWrapper } from "ssh2";
import { describe, expect, it, vi } from "vitest";
import { createDownloadStream } from "../../src/backend/download-stream.js";
function source(
data: Buffer,
options: { shortRead?: number; failAt?: number } = {},
) {
let pending = 0;
let peak = 0;
const closedWithPending: number[] = [];
const sftp = {
open: vi.fn((_path, _flags, _mode, cb) =>
cb(undefined, Buffer.from("handle")),
),
close: vi.fn((_handle, cb) => {
closedWithPending.push(pending);
cb();
}),
read: vi.fn((_handle, buffer, offset, length, position, cb) => {
peak = Math.max(peak, ++pending);
// Later file offsets can finish first.
setTimeout(
() => {
pending--;
if (position === options.failAt) return cb(new Error("read failed"));
const bytes = Math.min(
length,
options.shortRead ?? length,
Math.max(0, data.length - position),
);
if (bytes > 0) data.copy(buffer, offset, position, position + bytes);
cb(undefined, bytes);
},
position === 0 ? 10 : 0,
);
}),
};
return {
sftp: sftp as unknown as SFTPWrapper,
close: sftp.close,
read: sftp.read,
peak: () => peak,
closedWithPending,
};
}
async function contents(stream: ReturnType<typeof createDownloadStream>) {
const buffers: Buffer[] = [];
for await (const buffer of stream) buffers.push(buffer);
return Buffer.concat(buffers);
}
describe("SFTP download stream", () => {
it("pipelines bounded reads and preserves bytes despite out-of-order replies", async () => {
const data = Buffer.alloc(1024 * 1024 + 123);
for (let i = 0; i < data.length; i++) data[i] = (i * 13 + (i >>> 8)) % 256;
const remote = source(data);
expect(
await contents(createDownloadStream(remote.sftp, "/file", data.length)),
).toEqual(data);
expect(remote.peak()).toBe(8);
expect(remote.closedWithPending).toEqual([0]);
});
it("fills short server reads without losing or repeating bytes", async () => {
const data = Buffer.from("short reads ".repeat(10000));
const remote = source(data, { shortRead: 4096 });
expect(
await contents(createDownloadStream(remote.sftp, "/file", data.length)),
).toEqual(data);
expect(remote.closedWithPending).toEqual([0]);
});
it("handles empty files without issuing reads", async () => {
const remote = source(Buffer.alloc(0));
expect(
await contents(createDownloadStream(remote.sftp, "/empty", 0)),
).toEqual(Buffer.alloc(0));
expect(remote.read).not.toHaveBeenCalled();
expect(remote.close).toHaveBeenCalledOnce();
});
it("fails on premature EOF instead of returning uninitialized bytes", async () => {
const remote = source(Buffer.from("truncated"));
await expect(
contents(createDownloadStream(remote.sftp, "/file", 100000)),
).rejects.toThrow("File ended");
expect(remote.closedWithPending).toEqual([0]);
});
it("settles outstanding reads before closing on a read failure", async () => {
const remote = source(Buffer.alloc(1024 * 1024), { failAt: 32768 });
await expect(
contents(createDownloadStream(remote.sftp, "/file", 1024 * 1024)),
).rejects.toThrow("read failed");
expect(remote.read).toHaveBeenCalledTimes(8);
expect(remote.closedWithPending).toEqual([0]);
});
it("stops scheduling reads and closes the handle when destroyed mid-batch", async () => {
const remote = source(Buffer.alloc(1024 * 1024), { shortRead: 4096 });
const replies: Array<() => void> = [];
remote.read.mockImplementation(
(_handle, _buffer, _offset, _length, _position, cb) => {
replies.push(() => cb(undefined, 4096));
},
);
const stream = createDownloadStream(remote.sftp, "/file", 1024 * 1024);
stream.resume();
await nextTurn();
const closed = once(stream, "close");
stream.destroy();
for (const reply of replies) reply();
await closed;
expect(remote.read).toHaveBeenCalledTimes(8);
expect(remote.closedWithPending).toEqual([0]);
});
it("bounds read-ahead while the consumer is paused", async () => {
const remote = source(Buffer.alloc(8 * 1024 * 1024));
const stream = createDownloadStream(remote.sftp, "/file", 8 * 1024 * 1024);
stream.read(0);
await once(stream, "readable");
expect(remote.read).toHaveBeenCalledTimes(8);
const closed = once(stream, "close");
stream.destroy();
await closed;
expect(remote.closedWithPending).toEqual([0]);
});
});
@@ -0,0 +1,91 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: { user?: string; body?: unknown },
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any }>;
close: () => Promise<void>;
}
/**
* The plugin activated against a real in-memory database, its router mounted
* on an express app the way core mounts it: JSON parsed, the acting user set
* from the request, everything else left to the plugin.
*/
export async function startServer(
options: { permissions?: string[]; users?: string[] } = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir);
for (const user of options.users ?? ["user-1", "user-2"]) {
db.sqlite
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
.run(user, user);
}
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions ?? ["file-manager.use"],
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") ?? undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(method, path, { user = "user-1", body: given } = {}) {
const body = method === "GET" ? undefined : given;
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method,
headers: {
"x-test-user": user,
...(body !== undefined ? { "content-type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const text = await response.text();
return { status: response.status, body: text ? JSON.parse(text) : null };
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
@@ -0,0 +1,99 @@
import { EventEmitter } from "node:events";
import type { Express, Request, Response } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerFileListingRoutes } from "../../src/backend/list-routes.js";
import {
execChannel,
getSessionSftp,
type SSHSession,
} from "../../src/backend/session.js";
import { resolveHomeDirectory } from "../../src/backend/home-directory.js";
vi.mock("../../src/backend/session.js", () => ({
getSessionSftp: vi.fn(),
execChannel: vi.fn(),
}));
beforeEach(() => vi.resetAllMocks());
function setup(home: string, owned = true) {
const sftp = {
realpath: vi.fn((_path, done) => done(null, home)),
readdir: vi.fn((_path, done) => done(null, [])),
};
vi.mocked(getSessionSftp).mockResolvedValue(sftp as never);
let handler!: (req: Request, res: Response) => Promise<unknown>;
registerFileListingRoutes(
{
get: (_path, cb) => {
handler = cb;
},
} as Express,
{
ctx: {
currentActor: () => "user",
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
} as unknown as PluginContext,
sshSessions: {
s: { isConnected: true, activeOperations: 0 } as SSHSession,
},
activeListRequests: {},
verifySessionOwnership: () => owned,
},
);
const res = { on: vi.fn(), status: vi.fn().mockReturnThis(), json: vi.fn() };
return {
sftp,
res,
list: (path?: string) =>
handler(
{ query: { sessionId: "s", path } } as unknown as Request,
res as unknown as Response,
),
};
}
describe("remote login directory", () => {
it.each(["/root", "/srv/users/alice", "/"])(
"lists the actual SFTP directory %s",
async (home) => {
const { sftp, res, list } = setup(home);
await list(".");
await vi.waitFor(() =>
expect(res.json).toHaveBeenCalledWith({ files: [], path: home }),
);
expect(sftp.realpath).toHaveBeenCalledWith(".", expect.any(Function));
expect(sftp.readdir).toHaveBeenCalledWith(home, expect.any(Function));
expect(execChannel).not.toHaveBeenCalled();
},
);
it("preserves an explicit root instead of replacing it with home", async () => {
const { sftp, res, list } = setup("/home/alice");
await list("/");
await vi.waitFor(() =>
expect(res.json).toHaveBeenCalledWith({ files: [], path: "/" }),
);
expect(sftp.realpath).not.toHaveBeenCalled();
});
it("checks ownership before discovering the home directory", async () => {
const { res, list } = setup("/home/alice", false);
await list(".");
expect(res.status).toHaveBeenCalledWith(403);
expect(getSessionSftp).not.toHaveBeenCalled();
});
it("falls back to the shell login directory when SFTP is unavailable", async () => {
vi.mocked(getSessionSftp).mockRejectedValue(new Error("SFTP unavailable"));
vi.mocked(execChannel).mockImplementation((_session, command, done) => {
expect(command).toBe("pwd -P");
const stream = new EventEmitter();
done(undefined as never, stream as never);
queueMicrotask(() => {
stream.emit("data", Buffer.from("/srv/alice\n"));
stream.emit("close", 0);
});
});
await expect(resolveHomeDirectory({} as SSHSession)).resolves.toBe(
"/srv/alice",
);
});
});
@@ -0,0 +1,167 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers.js";
// file_manager_recent, file_manager_pinned, file_manager_shortcuts and
// transfer_recent as core's SQLite bootstrap created them before 2.9.0.
const LEGACY_DDL = `
CREATE TABLE file_manager_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
name TEXT NOT NULL,
path TEXT NOT NULL,
last_opened TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_file_manager_recent_user ON file_manager_recent (user_id, host_id);
CREATE TABLE file_manager_pinned (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
name TEXT NOT NULL,
path TEXT NOT NULL,
pinned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_file_manager_pinned_user ON file_manager_pinned (user_id, host_id);
CREATE TABLE file_manager_shortcuts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
name TEXT NOT NULL,
path TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_file_manager_shortcuts_user ON file_manager_shortcuts (user_id, host_id);
CREATE TABLE transfer_recent (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
source_host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
dest_host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
dest_path TEXT NOT NULL,
dest_path_label TEXT NOT NULL,
last_used TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_transfer_recent_user ON transfer_recent (user_id);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
const indexOn = (table: string) =>
(
db!.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
)
.all(table) as { name: string }[]
).map((row) => row.name);
describe("adopting the four file-manager tables", () => {
it("keeps every existing row when the legacy tables are there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(
"INSERT INTO users (id, username) VALUES ('user-1', 'user-1')",
);
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
sqlite.exec(LEGACY_DDL);
sqlite
.prepare(
"INSERT INTO file_manager_recent (user_id, host_id, name, path) VALUES (?, ?, ?, ?)",
)
.run("user-1", 1, "notes.txt", "/home/user/notes.txt");
sqlite
.prepare(
"INSERT INTO file_manager_pinned (user_id, host_id, name, path) VALUES (?, ?, ?, ?)",
)
.run("user-1", 1, "logs", "/var/log");
sqlite
.prepare(
"INSERT INTO file_manager_shortcuts (user_id, host_id, name, path) VALUES (?, ?, ?, ?)",
)
.run("user-1", 1, "Home", "/home/user");
sqlite
.prepare(
"INSERT INTO transfer_recent (user_id, source_host_id, dest_host_id, dest_path, dest_path_label) VALUES (?, ?, ?, ?, ?)",
)
.run("user-1", 1, 2, "/backups", "Backups");
},
});
expect(db.applied).toEqual(["0001_adopt_file_manager_tables"]);
for (const legacy of [
"file_manager_recent",
"file_manager_pinned",
"file_manager_shortcuts",
"transfer_recent",
]) {
expect(tableExists(legacy)).toBe(false);
}
for (const adopted of [
"p_file_manager_recent",
"p_file_manager_pinned",
"p_file_manager_shortcuts",
"p_file_manager_transfer_recent",
]) {
expect(tableExists(adopted)).toBe(true);
}
expect(
db.sqlite.prepare("SELECT name, path FROM p_file_manager_recent").all(),
).toEqual([{ name: "notes.txt", path: "/home/user/notes.txt" }]);
expect(
db.sqlite.prepare("SELECT name, path FROM p_file_manager_pinned").all(),
).toEqual([{ name: "logs", path: "/var/log" }]);
expect(
db.sqlite
.prepare("SELECT name, path FROM p_file_manager_shortcuts")
.all(),
).toEqual([{ name: "Home", path: "/home/user" }]);
expect(
db.sqlite
.prepare(
"SELECT dest_path, dest_path_label FROM p_file_manager_transfer_recent",
)
.all(),
).toEqual([{ dest_path: "/backups", dest_path_label: "Backups" }]);
expect(indexOn("p_file_manager_recent")).toEqual([
"idx_file_manager_recent_user",
]);
expect(indexOn("p_file_manager_pinned")).toEqual([
"idx_file_manager_pinned_user",
]);
expect(indexOn("p_file_manager_shortcuts")).toEqual([
"idx_file_manager_shortcuts_user",
]);
expect(indexOn("p_file_manager_transfer_recent")).toEqual([
"idx_transfer_recent_user",
]);
});
it("creates the tables fresh when there is no legacy one", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("file_manager_recent")).toBe(false);
expect(tableExists("p_file_manager_recent")).toBe(true);
expect(tableExists("p_file_manager_pinned")).toBe(true);
expect(tableExists("p_file_manager_shortcuts")).toBe(true);
expect(tableExists("p_file_manager_transfer_recent")).toBe(true);
expect(
db.sqlite.prepare("SELECT * FROM p_file_manager_recent").all(),
).toEqual([]);
});
});
@@ -0,0 +1,43 @@
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { pluginDir } from "./helpers";
function read(relative: string): string {
return fs.readFileSync(path.join(pluginDir, relative), "utf8");
}
/** Every "/path" the frontend's SSH client sends, without its parameters. */
function frontendPaths(): string[] {
const source = read("src/frontend/api/ssh-file-operations-api.ts");
const paths = new Set<string>();
for (const match of source.matchAll(/["`](\/[A-Za-z][\w-]*(?:\/[\w-]+)*)/g)) {
paths.add(match[1]);
}
return [...paths];
}
/** Every route the backend's express app registers. */
function backendPaths(): Set<string> {
const paths = new Set<string>();
const dir = path.join(pluginDir, "src/backend");
for (const file of fs
.readdirSync(dir)
.filter((name) => name.endsWith(".ts"))
.map((name) => `src/backend/${name}`)) {
for (const match of read(file).matchAll(
/app\.(?:get|post|put|delete|patch)\(\s*"(\/[^"]*)"/g,
)) {
paths.add(match[1].replace(/\/:[\w]+$/, ""));
}
}
return paths;
}
describe("file manager routes", () => {
it("serves every path the frontend's SSH client calls", () => {
const served = backendPaths();
const missing = frontendPaths().filter((p) => !served.has(p));
expect(missing).toEqual([]);
});
});
@@ -0,0 +1,68 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startServer, type TestServer } from "./helpers.js";
let server: TestServer;
beforeEach(async () => {
server = await startServer();
});
afterEach(async () => {
await server.close();
});
describe("recent/pinned/shortcuts", () => {
it("round-trips a recent file", async () => {
server.db.sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
const add = await server.request("POST", "/recent", {
body: { hostId: 1, path: "/etc/hosts", name: "hosts" },
});
expect(add.status).toBe(200);
const list = await server.request("GET", "/recent?hostId=1");
expect(list.status).toBe(200);
expect(list.body).toEqual([
expect.objectContaining({ name: "hosts", path: "/etc/hosts" }),
]);
});
it("refuses to pin the same path twice", async () => {
server.db.sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
const first = await server.request("POST", "/pinned", {
body: { hostId: 1, path: "/var/log", name: "logs" },
});
expect(first.status).toBe(200);
const second = await server.request("POST", "/pinned", {
body: { hostId: 1, path: "/var/log", name: "logs" },
});
expect(second.status).toBe(409);
});
});
describe("file-manager.use", () => {
it("refuses every route without it", async () => {
await server.close();
server = await startServer({ permissions: [] });
for (const [method, path] of [
["GET", "/status?sessionId=x"],
["POST", "/connect"],
["GET", "/recent?hostId=1"],
["POST", "/recent"],
["GET", "/pinned?hostId=1"],
["POST", "/pinned"],
["GET", "/shortcuts?hostId=1"],
["POST", "/shortcuts"],
["GET", "/activeTransfers"],
] as const) {
const res = await server.request(method, path, {
body: { hostId: 1, path: "/tmp", name: "tmp" },
});
expect(res.status, `${method} ${path}`).toBe(403);
expect(res.body.required).toBe("file-manager.use");
}
});
});
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it } from "vitest";
import {
clearTransferProfiles,
flushTransferProfiles,
getTransferProfile,
initializeTransferProfiles,
recordTransferProfile,
setTransferProfileStore,
type PersistedProfiles,
} from "../../src/backend/transfer-tuning.js";
afterEach(() => {
setTransferProfileStore(null);
clearTransferProfiles();
});
function memoryStore() {
let saved: PersistedProfiles | undefined;
return {
store: {
read: async () => saved,
write: async (value: PersistedProfiles) => {
saved = JSON.parse(JSON.stringify(value));
},
},
saved: () => saved,
};
}
describe("transfer profile store", () => {
it("saves learned profiles and reads them back after a restart", async () => {
const memory = memoryStore();
setTransferProfileStore(memory.store);
await initializeTransferProfiles();
recordTransferProfile("host-a:host-b", {
bytes: 50 * 1024 * 1024,
durationMs: 5000,
lanes: 4,
pipelineConcurrency: 8,
failed: false,
now: Date.now(),
});
await flushTransferProfiles();
expect(Object.keys(memory.saved()?.profiles ?? {})).toHaveLength(1);
// A fresh activation starts from what the store holds.
setTransferProfileStore(memory.store);
await initializeTransferProfiles();
expect(getTransferProfile("host-a:host-b")).toBeDefined();
});
it("works without a store", async () => {
await initializeTransferProfiles();
expect(getTransferProfile("nothing")).toBeUndefined();
});
});
@@ -0,0 +1,141 @@
import { EventEmitter } from "node:events";
import { PassThrough, Writable } from "node:stream";
import type { Express, Request, Response } from "express";
import { describe, expect, it, vi } from "vitest";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { registerFileContentRoutes } from "../../src/backend/content-routes";
import type { SSHSession } from "../../src/backend/session";
const { getSessionSftp } = vi.hoisted(() => ({ getSessionSftp: vi.fn() }));
vi.mock("../../src/backend/session.js", () => ({ getSessionSftp }));
function setup(route = "/uploadFileChunk") {
const routes = new Map<string, (req: Request, res: Response) => void>();
const app = {
get: vi.fn(),
post: (path: string, handler: (req: Request, res: Response) => void) =>
routes.set(path, handler),
} as unknown as Express;
registerFileContentRoutes(app, {
ctx: {
currentActor: () => "user-1",
log: { info: vi.fn(), error: vi.fn() },
} as unknown as PluginContext,
sshSessions: { s: { isConnected: true } as SSHSession },
verifySessionOwnership: () => true,
});
const req = Object.assign(new PassThrough(), {
query: {
sessionId: "s",
path: "/",
fileName: "upload.bin",
offset: "0",
totalSize: "4",
},
headers: { "content-type": "multipart/form-data; boundary=test-boundary" },
body: Buffer.from("data"),
complete: true,
});
const res = Object.assign(new EventEmitter(), {
writableFinished: false,
json: vi.fn(),
status: vi.fn().mockReturnThis(),
});
const start = () =>
routes.get(route)!(req as unknown as Request, res as unknown as Response);
return { req, res, start };
}
function sftp() {
// Keep the SSH write pending after HTTP has received the complete body.
const stream = new Writable({ write() {} });
return { stream, createWriteStream: vi.fn(() => stream) };
}
describe("chunk upload disconnects", () => {
it("stops SSH writes when the client disconnects after sending its body", async () => {
const remote = sftp();
getSessionSftp.mockResolvedValue(remote);
const { res, start } = setup();
start();
await Promise.resolve();
expect(remote.createWriteStream).toHaveBeenCalledOnce();
res.emit("close");
expect(remote.stream.destroyed).toBe(true);
remote.stream.emit("finish");
expect(res.json).not.toHaveBeenCalled();
});
it("does not open a remote file if cancelled while waiting for SFTP", async () => {
const remote = sftp();
let connect!: (value: typeof remote) => void;
getSessionSftp.mockReturnValue(
new Promise((resolve) => {
connect = resolve;
}),
);
const { res, start } = setup();
start();
res.emit("close");
connect(remote);
await Promise.resolve();
expect(remote.createWriteStream).not.toHaveBeenCalled();
expect(res.json).not.toHaveBeenCalled();
remote.stream.destroy();
});
it("does not cancel a successfully completed response", async () => {
const remote = sftp();
getSessionSftp.mockResolvedValue(remote);
const { res, start } = setup();
start();
await Promise.resolve();
remote.stream.emit("finish");
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ complete: true }),
);
res.writableFinished = true;
res.emit("close");
expect(remote.stream.destroyed).toBe(false);
remote.stream.destroy();
});
});
describe("multipart upload disconnects", () => {
it("keeps normal request completion alive but cancels unfinished SSH writes on response disconnect", async () => {
const remote = {
...sftp(),
unlink: vi.fn((_path, callback) => callback(null)),
};
getSessionSftp.mockResolvedValue(remote);
const { req, res, start } = setup("/uploadFileStream");
start();
req.end(
[
"--test-boundary",
'Content-Disposition: form-data; name="sessionId"',
"",
"s",
"--test-boundary",
'Content-Disposition: form-data; name="path"',
"",
"/",
"--test-boundary",
'Content-Disposition: form-data; name="file"; filename="a.txt"',
"Content-Type: text/plain",
"",
"data",
"--test-boundary--",
"",
].join("\r\n"),
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(remote.createWriteStream).toHaveBeenCalledWith("/a.txt");
req.emit("close");
expect(remote.stream.destroyed).toBe(false);
res.emit("close");
expect(remote.stream.destroyed).toBe(true);
expect(remote.unlink).toHaveBeenCalledWith("/a.txt", expect.any(Function));
expect(res.json).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { decodeWriteContent } from "../../src/backend/content-routes.js";
describe("decodeWriteContent", () => {
it("keeps text that happens to be valid base64", () => {
expect(decodeWriteContent("test").toString("utf8")).toBe("test");
expect(decodeWriteContent("abcd1234").toString("utf8")).toBe("abcd1234");
});
it("decodes base64 only when asked", () => {
const encoded = Buffer.from("local x = 1\n").toString("base64");
expect(decodeWriteContent(encoded, "base64").toString("utf8")).toBe(
"local x = 1\n",
);
});
it("passes buffers through", () => {
const buffer = Buffer.from([0, 1, 2]);
expect(decodeWriteContent(buffer)).toBe(buffer);
});
});
@@ -0,0 +1,141 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { FileManagerGrid } from "../../src/frontend/FileManagerGrid";
import type { FileItem } from "../../src/frontend/host-types";
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<typeof import("@termix/plugin-sdk/frontend")>()),
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getTotalSize: () => count * 29,
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({
index,
key: index,
start: index * 29,
size: 29,
})),
measureElement: () => {},
measure: () => {},
}),
}));
const file: FileItem = {
name: "notes.txt",
path: "/notes.txt",
type: "file",
modified: "2026-09-01",
owner: "owner",
permissions: "-rw-r--r--",
};
const props = {
files: [file],
selectedFiles: [file],
viewMode: "list" as const,
onFileOpen: vi.fn(),
onSelectionChange: vi.fn(),
onRefresh: vi.fn(),
};
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("remote file rename", () => {
it.each(["F2", "F6"])("starts rename with %s in the visible pane", (key) => {
vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockReturnValue(
document.body,
);
const onStartEdit = vi.fn();
render(<FileManagerGrid {...props} onStartEdit={onStartEdit} />);
fireEvent.keyDown(document, { key });
expect(onStartEdit).toHaveBeenCalledExactlyOnceWith(file);
});
it("does not rename files in a hidden pane", () => {
vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockReturnValue(
null,
);
const onStartEdit = vi.fn();
render(<FileManagerGrid {...props} onStartEdit={onStartEdit} />);
fireEvent.keyDown(document, { key: "F2" });
expect(onStartEdit).not.toHaveBeenCalled();
});
it("hides metadata while editing and commits the new name", () => {
const onRename = vi.fn();
const { rerender } = render(<FileManagerGrid {...props} />);
expect(screen.getByText(file.modified!)).toBeInTheDocument();
rerender(
<FileManagerGrid
{...props}
density="compact"
editingFile={file}
onRename={onRename}
/>,
);
expect(screen.queryByText(file.modified!)).not.toBeInTheDocument();
expect(screen.queryByText(file.permissions!)).not.toBeInTheDocument();
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "renamed.txt" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onRename).toHaveBeenCalledWith(file, "renamed.txt");
rerender(<FileManagerGrid {...props} />);
expect(screen.getByText(file.modified!)).toBeInTheDocument();
});
});
describe("external file drops", () => {
beforeEach(() => {
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
},
);
});
it.each(["list", "grid"] as const)(
"uploads dropped folders over a file in %s view",
(viewMode) => {
const onUploadItems = vi.fn();
render(
<FileManagerGrid
{...props}
viewMode={viewMode}
onUploadItems={onUploadItems}
/>,
);
const folder = {
name: "logs",
isDirectory: true,
isFile: false,
} as FileSystemEntry;
fireEvent.drop(screen.getByText(file.name), {
dataTransfer: {
types: ["Files"],
files: [],
getData: () => "",
items: [{ webkitGetAsEntry: () => folder }],
},
});
expect(onUploadItems).toHaveBeenCalledExactlyOnceWith([folder]);
},
);
it("uploads a plain OS file dropped over a row", () => {
const onUpload = vi.fn();
render(<FileManagerGrid {...props} onUpload={onUpload} />);
const files = [new File(["log"], "app.log")];
fireEvent.drop(screen.getByText(file.name), {
dataTransfer: { types: ["Files"], files, items: [], getData: () => "" },
});
expect(onUpload).toHaveBeenCalledExactlyOnceWith(files);
});
});
@@ -0,0 +1,101 @@
import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { FileManagerToolbar } from "../../src/frontend/FileManagerToolbar";
const props: React.ComponentProps<typeof FileManagerToolbar> = {
t: (key) => key,
currentPath: "/home/alice",
navIndex: 0,
navHistoryLength: 1,
isLoading: false,
sshSessionId: "session",
selectedFiles: [],
searchQuery: "report",
setSearchQuery: vi.fn(),
viewMode: "list",
setViewMode: vi.fn(),
density: "comfortable",
setDensity: vi.fn(),
sortBy: "name",
setSortBy: vi.fn(),
sortOrder: "asc",
setSortOrder: vi.fn(),
setMobileSidebarOpen: vi.fn(),
goBack: vi.fn(),
goForward: vi.fn(),
goUp: vi.fn(),
navigateTo: vi.fn(),
handleRefreshDirectory: vi.fn(),
handleDeleteFiles: vi.fn(),
handleCopyFiles: vi.fn(),
handleFilesDropped: vi.fn(),
handleCreateNewFolder: vi.fn(),
handleCreateNewFile: vi.fn(),
};
function toolbar(visible = true) {
const view = render(<FileManagerToolbar {...props} />);
const input = view.getByPlaceholderText(
"fileManager.searchFiles",
) as HTMLInputElement;
Object.defineProperty(input, "checkVisibility", { value: () => visible });
return { ...view, input };
}
afterEach(cleanup);
describe("file search shortcut", () => {
it.each([{ ctrlKey: true }, { metaKey: true }])(
"focuses and selects the search query with %j",
(modifier) => {
const { input } = toolbar();
expect(fireEvent.keyDown(document, { key: "f", ...modifier })).toBe(
false,
);
expect(input).toHaveFocus();
expect([input.selectionStart, input.selectionEnd]).toEqual([0, 6]);
},
);
it("does not handle the shortcut in a hidden tab", () => {
const { input } = toolbar(false);
expect(fireEvent.keyDown(document, { key: "f", ctrlKey: true })).toBe(true);
expect(input).not.toHaveFocus();
});
it("leaves editor and dialog shortcuts alone", () => {
const { input } = toolbar();
render(
<>
<textarea aria-label="editor" />
<div role="dialog">
<button>Dialog action</button>
</div>
</>,
);
for (const target of [
screen.getByLabelText("editor"),
screen.getByText("Dialog action"),
]) {
target.focus();
expect(fireEvent.keyDown(target, { key: "f", ctrlKey: true })).toBe(true);
expect(target).toHaveFocus();
expect(input).not.toHaveFocus();
}
});
it("does not intercept unmodified or already handled keys", () => {
const { input } = toolbar();
fireEvent.keyDown(document, { key: "f" });
const event = new KeyboardEvent("keydown", {
key: "f",
ctrlKey: true,
cancelable: true,
});
event.preventDefault();
document.dispatchEvent(event);
expect(input).not.toHaveFocus();
});
});
@@ -0,0 +1,503 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { LocalFilePane } from "../../src/frontend/LocalFilePane";
import {
LOCAL_FILES_DRAG_MIME,
REMOTE_FILES_DRAG_MIME,
} from "../../src/frontend/local-transfer-utils";
import type { LocalFileEntry } from "@termix/plugin-sdk/frontend";
// jsdom has no layout, so render every row instead of a virtual window.
vi.mock("@tanstack/react-virtual", () => ({
useVirtualizer: ({ count }: { count: number }) => ({
getTotalSize: () => count * 34,
getVirtualItems: () =>
Array.from({ length: count }, (_, index) => ({
index,
key: index,
start: index * 34,
size: 34,
})),
measureElement: () => {},
}),
}));
const sonnerToast = vi.hoisted(() => {
const fn = vi.fn() as ReturnType<typeof vi.fn> & Record<string, unknown>;
fn.success = vi.fn();
fn.error = vi.fn();
fn.info = vi.fn();
fn.warning = vi.fn();
fn.loading = vi.fn();
fn.dismiss = vi.fn();
return fn;
});
vi.mock("sonner", () => ({ toast: sonnerToast }));
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<typeof import("@termix/plugin-sdk/frontend")>()),
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts?.count !== undefined ? `${key}:${opts.count}` : key,
}),
}));
const HOME = "/Users/max";
const entries: LocalFileEntry[] = [
{
name: "projects",
path: `${HOME}/projects`,
type: "directory",
size: 0,
modifiedTimestamp: 1_700_000_000_000,
hidden: false,
},
{
name: "notes.txt",
path: `${HOME}/notes.txt`,
type: "file",
size: 1234,
modifiedTimestamp: 1_700_000_000_000,
hidden: false,
},
{
name: ".zshrc",
path: `${HOME}/.zshrc`,
type: "file",
size: 42,
modifiedTimestamp: 1_700_000_000_000,
hidden: true,
},
];
function installElectronApi() {
const list = vi.fn(async (dirPath: string) => ({
success: true as const,
path: dirPath,
parent:
dirPath === "/" ? null : dirPath.split("/").slice(0, -1).join("/") || "/",
entries: dirPath === HOME ? entries : [],
}));
const api = {
isElectron: true,
localFs: {
home: vi.fn(async () => ({
success: true as const,
home: HOME,
separator: "/",
platform: "darwin",
})),
list,
mkdir: vi.fn(),
createFile: vi.fn(async () => ({ success: true as const, path: "" })),
rename: vi.fn(async () => ({ success: true as const, path: "" })),
trash: vi.fn(async () => ({
success: true as const,
trashed: 1,
failed: [],
})),
ensureDir: vi.fn(),
walk: vi.fn(),
reveal: vi.fn(),
open: vi.fn(),
},
localTransfer: {
upload: vi.fn(),
download: vi.fn(),
cancel: vi.fn(),
onProgress: vi.fn(() => () => {}),
},
};
(window as unknown as { electronAPI: unknown }).electronAPI = api;
(window as unknown as { IS_ELECTRON: boolean }).IS_ELECTRON = true;
return api;
}
function makeDataTransfer(
types: string[],
data: Record<string, string> = {},
): DataTransfer {
const store: Record<string, string> = { ...data };
return {
types,
dropEffect: "none",
effectAllowed: "all",
files: [] as unknown as FileList,
items: [] as unknown as DataTransferItemList,
getData: (type: string) => store[type] ?? "",
setData: (type: string, value: string) => {
store[type] = value;
if (!types.includes(type)) types.push(type);
},
clearData: vi.fn(),
setDragImage: vi.fn(),
} as unknown as DataTransfer;
}
describe("LocalFilePane", () => {
beforeEach(() => {
localStorage.clear();
installElectronApi();
});
afterEach(() => {
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
});
it("lists the home directory on mount and remembers the last path", async () => {
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
await waitFor(() =>
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
);
expect(
screen.getByText("fileManager.localItemCount:3"),
).toBeInTheDocument();
expect(localStorage.getItem("termix:file-manager:local-pane:path")).toBe(
HOME,
);
});
it("navigates into a folder on double click", async () => {
const api = installElectronApi();
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
await waitFor(() =>
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
);
const row = document.querySelector(`[data-local-path="${HOME}/projects"]`);
expect(row).not.toBeNull();
fireEvent.click(row!, { detail: 2 });
await waitFor(() =>
expect(api.localFs.list).toHaveBeenCalledWith(`${HOME}/projects`),
);
});
it("accepts remote-grid drops and hands paths to the parent", async () => {
const onRemoteItemsDropped = vi.fn();
render(<LocalFilePane onRemoteItemsDropped={onRemoteItemsDropped} />);
await waitFor(() =>
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
);
const pane = screen.getByTestId("local-file-pane");
const payload = JSON.stringify({
type: "internal_files",
files: ["/srv/app/a.log", "/srv/app/dir"],
});
const dataTransfer = makeDataTransfer(
[REMOTE_FILES_DRAG_MIME, "text/plain"],
{ "text/plain": payload, [REMOTE_FILES_DRAG_MIME]: "1" },
);
fireEvent.dragEnter(pane, { dataTransfer });
expect(
screen.getByText("fileManager.dropToDownloadHere"),
).toBeInTheDocument();
fireEvent.drop(pane, { dataTransfer });
expect(onRemoteItemsDropped).toHaveBeenCalledWith(
["/srv/app/a.log", "/srv/app/dir"],
HOME,
);
expect(
screen.queryByText("fileManager.dropToDownloadHere"),
).not.toBeInTheDocument();
});
it("drops onto a folder row download into that folder", async () => {
const onRemoteItemsDropped = vi.fn();
render(<LocalFilePane onRemoteItemsDropped={onRemoteItemsDropped} />);
await waitFor(() =>
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
);
const folderRow = document.querySelector(
`[data-local-path="${HOME}/projects"]`,
)!;
const payload = JSON.stringify({
type: "internal_files",
files: ["/srv/app/a.log"],
});
const dataTransfer = makeDataTransfer(
[REMOTE_FILES_DRAG_MIME, "text/plain"],
{ "text/plain": payload, [REMOTE_FILES_DRAG_MIME]: "1" },
);
fireEvent.dragOver(folderRow, { dataTransfer });
fireEvent.drop(folderRow, { dataTransfer });
expect(onRemoteItemsDropped).toHaveBeenCalledWith(
["/srv/app/a.log"],
`${HOME}/projects`,
);
});
it("hides dotfiles when hidden files are toggled off", async () => {
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
await waitFor(() =>
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
);
expect(
document.querySelector(`[data-local-path="${HOME}/.zshrc"]`),
).not.toBeNull();
fireEvent.click(screen.getByTitle("fileManager.localHideHidden"));
expect(
document.querySelector(`[data-local-path="${HOME}/.zshrc"]`),
).toBeNull();
expect(
screen.getByText("fileManager.localItemCount:2"),
).toBeInTheDocument();
});
it("opens a context menu for a row with entry actions", async () => {
const onUploadToRemote = vi.fn();
render(
<LocalFilePane
onRemoteItemsDropped={vi.fn()}
onUploadToRemote={onUploadToRemote}
/>,
);
await waitFor(() =>
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
);
const row = document.querySelector(
`[data-local-path="${HOME}/notes.txt"]`,
)!;
fireEvent.contextMenu(row, { clientX: 40, clientY: 50 });
const menu = screen.getByTestId("local-file-context-menu");
expect(menu).toBeInTheDocument();
expect(screen.getByText("fileManager.localOpen")).toBeInTheDocument();
expect(screen.getByText("fileManager.rename")).toBeInTheDocument();
expect(
screen.getByText("fileManager.localMoveToTrash"),
).toBeInTheDocument();
// Background-only actions are not offered for a row.
expect(screen.queryByText("fileManager.newFolder")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("fileManager.localUploadToRemote"));
expect(onUploadToRemote).toHaveBeenCalledWith([`${HOME}/notes.txt`]);
expect(
screen.queryByTestId("local-file-context-menu"),
).not.toBeInTheDocument();
});
it("acts on the whole selection when right-clicking a selected row", async () => {
const onUploadToRemote = vi.fn();
render(
<LocalFilePane
onRemoteItemsDropped={vi.fn()}
onUploadToRemote={onUploadToRemote}
/>,
);
await waitFor(() =>
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
);
const projects = document.querySelector(
`[data-local-path="${HOME}/projects"]`,
)!;
const notes = document.querySelector(
`[data-local-path="${HOME}/notes.txt"]`,
)!;
fireEvent.click(projects);
fireEvent.click(notes, { metaKey: true });
fireEvent.contextMenu(notes, { clientX: 40, clientY: 50 });
fireEvent.click(screen.getByText("fileManager.localUploadToRemoteMany:2"));
expect(onUploadToRemote).toHaveBeenCalledWith(
expect.arrayContaining([`${HOME}/projects`, `${HOME}/notes.txt`]),
);
expect(onUploadToRemote.mock.calls[0][0]).toHaveLength(2);
});
it("offers folder-level actions on the background and creates a file", async () => {
const api = installElectronApi();
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
await waitFor(() =>
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
);
const body = screen
.getByText("fileManager.localItemCount:3")
.closest('[data-testid="local-file-pane"]')!
.querySelector(".thin-scrollbar")!;
fireEvent.contextMenu(body, { clientX: 100, clientY: 200 });
expect(screen.getByText("fileManager.newFolder")).toBeInTheDocument();
expect(screen.getByText("fileManager.refresh")).toBeInTheDocument();
expect(screen.queryByText("fileManager.rename")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("fileManager.newFile"));
const input = screen.getByPlaceholderText("fileManager.newFile");
fireEvent.change(input, { target: { value: "todo.md" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() =>
expect(api.localFs.createFile).toHaveBeenCalledWith(HOME, "todo.md"),
);
});
it("renames inline from the context menu", async () => {
const api = installElectronApi();
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
await waitFor(() =>
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
);
const row = document.querySelector(
`[data-local-path="${HOME}/notes.txt"]`,
)!;
fireEvent.contextMenu(row, { clientX: 40, clientY: 50 });
fireEvent.click(screen.getByText("fileManager.rename"));
const input = screen.getByDisplayValue("notes.txt");
fireEvent.change(input, { target: { value: "renamed.txt" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() =>
expect(api.localFs.rename).toHaveBeenCalledWith(
`${HOME}/notes.txt`,
"renamed.txt",
),
);
});
it("asks for confirmation before moving to the trash", async () => {
const api = installElectronApi();
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
await waitFor(() =>
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
);
const row = document.querySelector(
`[data-local-path="${HOME}/notes.txt"]`,
)!;
fireEvent.contextMenu(row, { clientX: 40, clientY: 50 });
fireEvent.click(screen.getByText("fileManager.localMoveToTrash"));
// Nothing is trashed until the toast's confirm action is clicked.
expect(api.localFs.trash).not.toHaveBeenCalled();
expect(sonnerToast).toHaveBeenCalledWith(
"fileManager.localTrashConfirmSingle",
expect.objectContaining({ action: expect.anything() }),
);
const call = sonnerToast.mock.calls.find(
(c) => c[0] === "fileManager.localTrashConfirmSingle",
)!;
(call[1] as { action: { onClick: () => void } }).action.onClick();
await waitFor(() =>
expect(api.localFs.trash).toHaveBeenCalledWith([`${HOME}/notes.txt`]),
);
});
it("shows a pinned .. entry that goes up on double click and accepts drops", async () => {
const api = installElectronApi();
const onRemoteItemsDropped = vi.fn();
render(<LocalFilePane onRemoteItemsDropped={onRemoteItemsDropped} />);
await waitFor(() =>
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
);
const parentRow = document.querySelector("[data-parent-entry]");
expect(parentRow).not.toBeNull();
// It is the first row, above the real entries.
const firstRow = screen
.getByTestId("local-file-pane")
.querySelector(".thin-scrollbar > *");
expect(firstRow).toBe(parentRow);
const payload = JSON.stringify({
type: "internal_files",
files: ["/srv/app/a.log"],
});
const dataTransfer = makeDataTransfer(
[REMOTE_FILES_DRAG_MIME, "text/plain"],
{ "text/plain": payload, [REMOTE_FILES_DRAG_MIME]: "1" },
);
fireEvent.dragOver(parentRow!, { dataTransfer });
fireEvent.drop(parentRow!, { dataTransfer });
expect(onRemoteItemsDropped).toHaveBeenCalledWith(
["/srv/app/a.log"],
"/Users",
);
fireEvent.click(parentRow!, { detail: 2 });
await waitFor(() =>
expect(api.localFs.list).toHaveBeenCalledWith("/Users"),
);
});
it("lets the header context menu hide and show columns", async () => {
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
await waitFor(() =>
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
);
const kindHeader = screen.getByText("fileManager.kind");
const kindCellsBefore = document.querySelectorAll(
`[data-local-path="${HOME}/notes.txt"] > span`,
).length;
fireEvent.contextMenu(kindHeader, { clientX: 300, clientY: 60 });
const menu = screen.getByTestId("column-visibility-menu");
expect(menu).toBeInTheDocument();
fireEvent.click(
screen.getByRole("menuitemcheckbox", { name: "fileManager.kind" }),
);
// The sortable header button is gone; only the menu's checkbox remains.
expect(
screen.queryByRole("button", { name: "fileManager.kind" }),
).toBeNull();
expect(
document.querySelectorAll(`[data-local-path="${HOME}/notes.txt"] > span`)
.length,
).toBe(kindCellsBefore - 1);
expect(
JSON.parse(
localStorage.getItem("termix:file-manager:columns:local:hidden")!,
),
).toEqual(["kind"]);
});
it("ignores OS file drags (those belong to the remote grid)", async () => {
const onRemoteItemsDropped = vi.fn();
render(<LocalFilePane onRemoteItemsDropped={onRemoteItemsDropped} />);
await waitFor(() =>
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
);
const pane = screen.getByTestId("local-file-pane");
const dataTransfer = makeDataTransfer(["Files"]);
fireEvent.dragEnter(pane, { dataTransfer });
expect(
screen.queryByText("fileManager.dropToDownloadHere"),
).not.toBeInTheDocument();
fireEvent.drop(pane, { dataTransfer });
expect(onRemoteItemsDropped).not.toHaveBeenCalled();
});
it("puts a typed payload on drags that start in the pane", async () => {
render(<LocalFilePane onRemoteItemsDropped={vi.fn()} />);
await waitFor(() =>
expect(screen.getByDisplayValue(HOME)).toBeInTheDocument(),
);
const row = document.querySelector(`[data-local-path="${HOME}/notes.txt"]`);
expect(row).not.toBeNull();
const dataTransfer = makeDataTransfer([]);
fireEvent.dragStart(row!, { dataTransfer });
expect(dataTransfer.types).toContain(LOCAL_FILES_DRAG_MIME);
expect(JSON.parse(dataTransfer.getData("text/plain"))).toEqual({
type: "local_files",
paths: [`${HOME}/notes.txt`],
});
});
});
@@ -0,0 +1,277 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { toast } from "sonner";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SftpTransferTab } from "../../src/frontend/SftpTransferTab";
const api = vi.hoisted(() => ({
confirmBeforeTrash: undefined as boolean | undefined,
addTransferRecent: vi.fn(),
browseSSHDirectory: vi.fn(),
changeSSHPermissions: vi.fn(),
createSSHFolder: vi.fn(),
deleteSSHItem: vi.fn(),
ensureSSHSessionForHost: vi.fn(),
getSSHHosts: vi.fn(),
getTransferProgressPercent: vi.fn(() => undefined),
renameSSHItem: vi.fn(),
transferToHost: vi.fn(),
beginTransferProgressMonitoring: vi.fn(),
}));
vi.mock("../../src/frontend/api/transfer-api", () => ({
addTransferRecent: api.addTransferRecent,
getTransferProgressPercent: api.getTransferProgressPercent,
transferToHost: api.transferToHost,
}));
vi.mock("../../src/frontend/api/hosts", () => ({
getSSHHosts: api.getSSHHosts,
}));
vi.mock("../../src/frontend/api/ssh-file-operations-api", () => ({
browseSSHDirectory: api.browseSSHDirectory,
changeSSHPermissions: api.changeSSHPermissions,
createSSHFolder: api.createSSHFolder,
deleteSSHItem: api.deleteSSHItem,
ensureSSHSessionForHost: api.ensureSSHSessionForHost,
renameSSHItem: api.renameSSHItem,
}));
vi.mock("../../src/frontend/transferProgressMonitor", () => ({
beginTransferProgressMonitoring: api.beginTransferProgressMonitoring,
}));
vi.mock("../../src/frontend/transferMetricsFormat", () => ({
createFormatTransferMetrics: () => () => "",
}));
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<typeof import("@termix/plugin-sdk/frontend")>()),
useTranslation: () => ({ t: (key: string) => key, language: "en" }),
useSettings: () => ({
values: { confirmBeforeTrash: api.confirmBeforeTrash },
}),
}));
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
beforeEach(() => {
vi.clearAllMocks();
api.confirmBeforeTrash = undefined;
api.getSSHHosts.mockResolvedValue([
{
id: 1,
name: "prod",
ip: "10.0.0.1",
connectionType: "ssh",
pluginSettings: {
"file-manager": { enableFileManager: true, defaultPath: "/srv" },
},
},
{
id: 2,
name: "backup",
ip: "10.0.0.2",
connectionType: "ssh",
pluginSettings: {
"file-manager": { enableFileManager: true, defaultPath: "/srv" },
},
},
]);
api.ensureSSHSessionForHost.mockImplementation(async (host) => ({
state: "ready",
sessionId: String(host.id),
}));
api.browseSSHDirectory.mockImplementation(
async (sessionId: string, path: string) => ({
status: "ok",
path,
files: [
{
name: `remote-${sessionId}.txt`,
type: "file",
size: 24,
modified: "2026-07-18T10:30:00.000Z",
},
],
}),
);
api.transferToHost.mockResolvedValue({ transferId: "transfer-1" });
api.beginTransferProgressMonitoring.mockReturnValue({
toastId: "toast-1",
waitForCompletion: Promise.resolve({
transferId: "transfer-1",
status: "success",
phase: "transferring",
}),
});
});
async function selectHosts() {
const selects = await screen.findAllByRole("combobox");
fireEvent.change(selects[0], { target: { value: "1" } });
fireEvent.change(selects[1], { target: { value: "2" } });
await screen.findByText("remote-1.txt");
await screen.findByText("remote-2.txt");
}
describe("SftpTransferTab", () => {
it("opens the server login directory when no default path is configured", async () => {
const hosts = await api.getSSHHosts();
api.getSSHHosts.mockResolvedValue(
hosts.map((host: object) => ({ ...host, pluginSettings: {} })),
);
api.browseSSHDirectory.mockImplementation(
async (sessionId: string, path: string) => ({
status: "ok",
path: path === "." ? `/srv/home-${sessionId}` : path,
files: [
{
name: `remote-${sessionId}.txt`,
type: "file",
path: `/srv/home-${sessionId}/remote-${sessionId}.txt`,
size: 1,
},
],
}),
);
render(<SftpTransferTab />);
await selectHosts();
await waitFor(() => {
expect(api.browseSSHDirectory).toHaveBeenCalledWith("1", ".");
expect(api.browseSSHDirectory).toHaveBeenCalledWith("2", ".");
});
expect(await screen.findByDisplayValue("/srv/home-1")).toBeTruthy();
expect(await screen.findByDisplayValue("/srv/home-2")).toBeTruthy();
});
it("loads file manager-enabled hosts into both host pickers", async () => {
render(<SftpTransferTab />);
const selects = await screen.findAllByRole("combobox");
expect(selects).toHaveLength(2);
expect(api.getSSHHosts).toHaveBeenCalled();
});
it("copies a source server file to the destination server via the context menu", async () => {
render(<SftpTransferTab />);
await selectHosts();
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
await userEvent.click(screen.getByText("sftpTransfer.copyToTarget"));
await waitFor(() => {
// A single file transfer sends the full destination path, filename
// included, rather than just the directory. See #1304: sending a file on
// its own used to drop it.
expect(api.transferToHost).toHaveBeenCalledWith(
"1",
["/srv/remote-1.txt"],
"2",
"/srv/remote-1.txt",
false,
"auto",
);
});
});
it("records the destination as a transfer recent after a successful copy", async () => {
render(<SftpTransferTab />);
await selectHosts();
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
await userEvent.click(screen.getByText("sftpTransfer.copyToTarget"));
await waitFor(() => {
expect(api.addTransferRecent).toHaveBeenCalledWith(1, 2, "/srv", "/srv");
});
});
it("blocks a same-host transfer where the destination is inside the source path", async () => {
render(<SftpTransferTab />);
const selects = await screen.findAllByRole("combobox");
fireEvent.change(selects[0], { target: { value: "1" } });
fireEvent.change(selects[1], { target: { value: "1" } });
const rows = await screen.findAllByText("remote-1.txt");
// Destination pane starts at the same "/srv" listing as the source, so
// browsing into the selected source file's own path (as if it were a
// folder) makes the destination nested inside the source selection.
const destPathInput = screen.getAllByDisplayValue("/srv")[1];
fireEvent.change(destPathInput, {
target: { value: "/srv/remote-1.txt" },
});
fireEvent.keyDown(destPathInput, { key: "Enter" });
await waitFor(() =>
expect(api.browseSSHDirectory).toHaveBeenCalledTimes(3),
);
fireEvent.contextMenu(rows[0]);
await userEvent.click(screen.getByText("sftpTransfer.copyToTarget"));
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
"sftpTransfer.destinationInsideSource",
);
});
expect(api.transferToHost).not.toHaveBeenCalled();
});
it("renames a remote file from the row context menu", async () => {
render(<SftpTransferTab />);
await selectHosts();
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
await userEvent.click(screen.getByText("sftpTransfer.rename"));
const nameInput = screen.getByDisplayValue("remote-1.txt");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "renamed.txt");
await userEvent.click(screen.getByText("sftpTransfer.save"));
await waitFor(() => {
expect(api.renameSSHItem).toHaveBeenCalledWith(
"1",
"/srv/remote-1.txt",
"renamed.txt",
);
});
});
it("moves files to trash directly when confirmation is disabled", async () => {
api.confirmBeforeTrash = false;
render(<SftpTransferTab />);
await selectHosts();
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
await userEvent.click(screen.getByText("sftpTransfer.delete"));
await waitFor(() =>
expect(api.deleteSSHItem).toHaveBeenCalledWith(
"1",
"/srv/remote-1.txt",
false,
),
);
expect(screen.queryByText("sftpTransfer.deleteSelectedItems")).toBeNull();
});
it("deletes a remote file after confirming", async () => {
render(<SftpTransferTab />);
await selectHosts();
fireEvent.contextMenu(screen.getByText("remote-1.txt"));
await userEvent.click(screen.getByText("sftpTransfer.delete"));
expect(api.deleteSSHItem).not.toHaveBeenCalled();
const confirmButtons = await screen.findAllByText("sftpTransfer.delete");
await userEvent.click(confirmButtons[confirmButtons.length - 1]);
await waitFor(() => {
expect(api.deleteSSHItem).toHaveBeenCalledWith(
"1",
"/srv/remote-1.txt",
false,
);
});
});
});
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it } from "vitest";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
const declared = (views?: Array<{ id: string }>) =>
(views ?? []).map((view) => view.id).sort();
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe(`${manifest.id} activate`, () => {
it("activates and registers only views its manifest declares", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const contributes = manifest.contributes as unknown as Record<
string,
Array<{ id: string }> | undefined
>;
const tabs = declared(contributes.tabs);
const panels = [...declared(contributes.panels), ...tabs];
for (const id of rendered.registered.tabs()) expect(tabs).toContain(id);
for (const id of rendered.registered.panels()) expect(panels).toContain(id);
});
it("registers the files and sftp tabs", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.tabs().sort()).toEqual(["files", "sftp"]);
});
it("registers the sftp rail item pointing at the sftp tab", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const railItems = rendered.registered.railItems();
expect(railItems.map((item) => item.id)).toContain("sftp");
});
it("registers a files host action opening the files tab", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const hostActions = rendered.registered.hostActions();
expect(hostActions).toContainEqual(
expect.objectContaining({ id: "files", tabType: "files" }),
);
});
it("leaves its host fields to the schema-driven Plugins tab", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.hostEditorSections()).toEqual([]);
});
it("registers its open actions and the tab menu entry", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.actions().sort()).toEqual([
"file-manager.openFromTab",
"files.openEditor",
"files.openHost",
]);
expect(rendered.registered.slot("tab.menu")).toEqual([
"file-manager.openFromTab",
]);
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]);
expect(app.registered.hostActions()).toEqual([]);
expect(app.registered.hostEditorSections()).toEqual([]);
expect(app.registered.dashboardCards()).toEqual([]);
expect(app.registered.settingsComponents()).toEqual([]);
expect(app.registered.actions()).toEqual([]);
});
});
@@ -0,0 +1,146 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const fileManagerApiMock = vi.hoisted(() => ({
post: vi.fn(async () => ({ data: { complete: false } })),
postForm: vi.fn(async () => ({ data: {} })),
}));
vi.mock("../../../src/frontend/api/client", () => ({
fileManagerApi: () => ({ defaults: {} }),
getFileManagerApiForSession: () => fileManagerApiMock,
handleApiError: (error: unknown) => {
throw error;
},
setSessionOrigin: vi.fn(),
clearSessionOrigin: vi.fn(),
}));
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
...(await importOriginal<object>()),
resolveConnectionOrigin: vi.fn(),
createFrontendLogger: () => ({
info: vi.fn(),
success: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock("../../../src/frontend/lib/file-list-request-cache", () => ({
getCachedFileList: vi.fn(),
}));
vi.mock("../../../src/frontend/lib/file-content-request-cache", () => ({
getCachedFileContent: vi.fn(),
invalidateCachedFileContent: vi.fn(),
}));
import { uploadSSHFile } from "../../../src/frontend/api/ssh-file-operations-api";
describe("chunked SSH file uploads", () => {
beforeEach(() => {
fileManagerApiMock.post.mockClear();
fileManagerApiMock.postForm.mockClear();
});
it("sends raw chunks with the byte offset expected by the server", async () => {
const fileSize = 1.5 * 1024 * 1024 * 1024 + 1;
const file = {
size: fileSize,
slice: vi.fn(() => new Blob(["chunk"])),
} as unknown as File;
await uploadSSHFile("session-1", "/uploads", "archive.img", file);
expect(fileManagerApiMock.post).toHaveBeenCalledTimes(193);
expect(fileManagerApiMock.post).toHaveBeenNthCalledWith(
1,
"/uploadFileChunk",
expect.any(Blob),
{
params: {
sessionId: "session-1",
path: "/uploads",
fileName: "archive.img",
offset: 0,
totalSize: fileSize,
},
headers: { "Content-Type": "application/octet-stream" },
timeout: 0,
signal: undefined,
},
);
expect(fileManagerApiMock.post).toHaveBeenLastCalledWith(
"/uploadFileChunk",
expect.any(Blob),
expect.objectContaining({
params: expect.objectContaining({ offset: 1.5 * 1024 * 1024 * 1024 }),
}),
);
});
});
describe("upload cancellation", () => {
it("passes the signal to multipart uploads", async () => {
const controller = new AbortController();
await uploadSSHFile(
"s",
"/",
"a.txt",
new File(["data"], "a.txt"),
undefined,
undefined,
undefined,
controller.signal,
);
expect(fileManagerApiMock.postForm).toHaveBeenLastCalledWith(
"/uploadFileStream",
expect.any(FormData),
expect.objectContaining({ signal: controller.signal }),
);
});
it("does not start an already cancelled upload", async () => {
const controller = new AbortController();
controller.abort();
fileManagerApiMock.postForm.mockClear();
await expect(
uploadSSHFile(
"s",
"/",
"a.txt",
new File(["data"], "a.txt"),
undefined,
undefined,
undefined,
controller.signal,
),
).rejects.toMatchObject({ name: "AbortError" });
expect(fileManagerApiMock.postForm).not.toHaveBeenCalled();
});
it("stops scheduling chunks after cancellation", async () => {
const controller = new AbortController();
fileManagerApiMock.post.mockClear();
const file = {
size: 2 * 1024 ** 3,
slice: vi.fn(() => new Blob(["chunk"])),
} as unknown as File;
await expect(
uploadSSHFile(
"s",
"/",
"large.bin",
file,
undefined,
undefined,
() => controller.abort(),
controller.signal,
),
).rejects.toMatchObject({ name: "AbortError" });
expect(fileManagerApiMock.post).toHaveBeenCalledOnce();
expect(fileManagerApiMock.post).toHaveBeenCalledWith(
"/uploadFileChunk",
expect.any(Blob),
expect.objectContaining({ signal: controller.signal }),
);
expect(file.slice).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,43 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { MarkdownRenderer } from "../../../src/frontend/components/MarkdownRenderer";
afterEach(cleanup);
describe("Markdown preview text copying", () => {
it.each([false, true])(
"supports copying in compact=%s previews",
(compact) => {
const fileShortcut = vi.fn();
document.addEventListener("keydown", fileShortcut);
try {
const { container } = render(
<div className="select-none">
<MarkdownRenderer
compact={compact}
content={"# Preview\n\nCopy this text."}
/>
</div>,
);
const preview = container.querySelector<HTMLElement>(".select-text")!;
preview.focus();
expect(preview).toHaveFocus();
expect(
screen.getByRole("heading", { name: "Preview" }),
).toBeInTheDocument();
for (const modifier of [{ ctrlKey: true }, { metaKey: true }]) {
expect(fireEvent.keyDown(preview, { key: "c", ...modifier })).toBe(
true,
);
}
expect(fileShortcut).not.toHaveBeenCalled();
fireEvent.keyDown(preview, { key: "Escape" });
expect(fileShortcut).toHaveBeenCalledTimes(1);
expect(fireEvent.contextMenu(preview)).toBe(true);
} finally {
document.removeEventListener("keydown", fileShortcut);
}
},
);
});
@@ -0,0 +1,61 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { TransferProgressBar } from "../../../src/frontend/components/TransferProgressBar";
afterEach(cleanup);
describe("TransferProgressBar", () => {
it("exposes determinate progress and drives the fill with a transform", () => {
const { container } = render(
<TransferProgressBar value={42} label="Uploading archive" />,
);
const progress = screen.getByRole("progressbar", {
name: "Uploading archive",
});
expect(progress.getAttribute("aria-valuenow")).toBe("42");
expect(progress.getAttribute("aria-busy")).toBe("false");
expect(
container
.querySelector<HTMLElement>(".transfer-progress-fill")
?.style.getPropertyValue("--transfer-progress"),
).toBe("0.42");
});
it("clamps invalid percentages before exposing them", () => {
const { rerender } = render(
<TransferProgressBar value={140} label="Uploading archive" />,
);
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
"100",
);
rerender(<TransferProgressBar value={-20} label="Uploading archive" />);
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
"0",
);
});
it("marks unknown and stalled progress without inventing a percentage", () => {
const { container } = render(
<TransferProgressBar label="Reconnecting" stalled />,
);
const progress = screen.getByRole("progressbar");
expect(progress.hasAttribute("aria-valuenow")).toBe(false);
expect(progress.getAttribute("aria-busy")).toBe("true");
expect(progress.getAttribute("data-stalled")).toBe("true");
expect(
container
.querySelector(".transfer-progress-fill")
?.classList.contains("transfer-progress-indeterminate"),
).toBe(true);
expect(
container
.querySelector(".transfer-progress-fill")
?.classList.contains("transfer-progress-stalled"),
).toBe(true);
});
});
@@ -0,0 +1,180 @@
import "@testing-library/jest-dom/vitest";
import React from "react";
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "sonner";
import { FileWindow } from "../../../src/frontend/components/FileWindow";
import { DiffViewer } from "../../../src/frontend/components/DiffViewer";
import type { FileItem, SSHHost } from "../../../src/frontend/host-types";
const api = vi.hoisted(() => ({
downloadSSHFileStream: vi.fn(),
downloadSSHFile: vi.fn(async () => ({ content: "eA==" })),
readSSHFile: vi.fn(async () => ({ content: "text", encoding: "utf8" })),
getSSHStatus: vi.fn(async () => ({ connected: true })),
connectSSH: vi.fn(),
writeSSHFile: vi.fn(),
}));
vi.mock("../../../src/frontend/api/ssh-file-operations-api", () => api);
vi.mock("sonner", () => ({
toast: { loading: vi.fn(), success: vi.fn(), error: vi.fn() },
}));
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<typeof import("@termix/plugin-sdk/frontend")>()),
useTranslation: () => ({
t: (key: string, params?: { name?: string }) =>
params?.name ? `${key}: ${params.name}` : key,
}),
}));
vi.mock("../../../src/frontend/components/WindowManager", () => ({
useWindowManager: () => ({
windows: [{ id: "preview" }],
closeWindow: vi.fn(),
maximizeWindow: vi.fn(),
focusWindow: vi.fn(),
}),
}));
vi.mock("../../../src/frontend/components/DraggableWindow", () => ({
DraggableWindow: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
}));
vi.mock("../../../src/frontend/components/FileViewer", () => ({
FileViewer: ({ onDownload }: { onDownload: () => void }) => (
<button onClick={onDownload}>download-preview</button>
),
}));
vi.mock("@monaco-editor/react", () => ({ DiffEditor: () => null }));
const file1: FileItem = { name: "one.txt", path: "/one.txt", type: "file" };
const file2: FileItem = { name: "two.txt", path: "/two.txt", type: "file" };
const host = { id: 1, name: "host", ip: "127.0.0.1", port: 22 } as SSHHost;
let transfers: Array<{
progress: (event: { loaded: number; total?: number }) => void;
resolve: () => void;
reject: (error: Error) => void;
}>;
beforeEach(() => {
vi.clearAllMocks();
api.connectSSH.mockReset();
let nextId = 0;
vi.mocked(toast.loading).mockImplementation(
(_node, options) => options?.id ?? ++nextId,
);
transfers = [];
api.downloadSSHFileStream.mockImplementation(
(_session, _path, progress) =>
new Promise<void>((resolve, reject) => {
transfers.push({ progress, resolve, reject });
}),
);
});
afterEach(cleanup);
async function open(kind: string) {
if (kind === "preview") {
render(
<FileWindow
windowId="preview"
file={{ ...file1 }}
sshSessionId="session"
sshHost={host}
/>,
);
return screen.findByRole("button", { name: "download-preview" });
}
render(
<DiffViewer
file1={file1}
file2={file2}
sshSessionId="session"
sshHost={host}
/>,
);
return screen.findByRole("button", { name: file1.name });
}
describe.each(["preview", "comparison"])("%s download feedback", (kind) => {
it("clears the loading state if the connection check fails", async () => {
const button = await open(kind);
api.getSSHStatus.mockRejectedValueOnce(new Error("connection failed"));
api.connectSSH.mockRejectedValueOnce(new Error("connection failed"));
fireEvent.click(button);
await waitFor(() => expect(toast.error).toHaveBeenCalled());
const id = vi.mocked(toast.loading).mock.results[0].value;
expect(toast.error).toHaveBeenLastCalledWith(expect.any(String), {
id,
duration: undefined,
});
expect(api.downloadSSHFileStream).not.toHaveBeenCalled();
});
it("updates one progress toast, then replaces it on completion", async () => {
fireEvent.click(await open(kind));
await waitFor(() => expect(transfers).toHaveLength(1));
const id = vi.mocked(toast.loading).mock.results[0].value;
expect(toast.loading).toHaveBeenCalledWith(expect.anything(), {
duration: Infinity,
});
expect(toast.success).not.toHaveBeenCalled();
act(() => transfers[0].progress({ loaded: 50, total: 100 }));
const last = vi.mocked(toast.loading).mock.calls.at(-1)!;
expect(last[1]).toEqual({ id, duration: Infinity });
render(<>{last[0] as React.ReactNode}</>);
expect(screen.getByRole("progressbar")).toHaveAttribute(
"aria-valuenow",
"50",
);
await act(async () => transfers[0].resolve());
expect(toast.success).toHaveBeenCalledWith(expect.any(String), {
id,
duration: undefined,
});
expect(api.downloadSSHFile).not.toHaveBeenCalled();
});
it("replaces the loading toast with an error when the download fails", async () => {
fireEvent.click(await open(kind));
await waitFor(() => expect(transfers).toHaveLength(1));
const id = vi.mocked(toast.loading).mock.results[0].value;
await act(async () => transfers[0].reject(new Error("read failed")));
expect(toast.error).toHaveBeenCalledWith(
expect.stringContaining("read failed"),
{ id, duration: undefined },
);
expect(toast.success).not.toHaveBeenCalled();
});
});
it("keeps simultaneous comparison downloads in separate toasts", async () => {
fireEvent.click(await open("comparison"));
fireEvent.click(screen.getByRole("button", { name: file2.name }));
await waitFor(() => expect(transfers).toHaveLength(2));
const ids = vi
.mocked(toast.loading)
.mock.results.map((result) => result.value);
expect(ids[0]).not.toBe(ids[1]);
act(() => transfers[0].progress({ loaded: 1 }));
expect(toast.loading).toHaveBeenLastCalledWith(expect.anything(), {
id: ids[0],
duration: Infinity,
});
act(() => transfers[1].progress({ loaded: 2 }));
expect(toast.loading).toHaveBeenLastCalledWith(expect.anything(), {
id: ids[1],
duration: Infinity,
});
await act(async () => {
transfers[1].resolve();
transfers[0].resolve();
});
expect(toast.success).toHaveBeenCalledTimes(2);
});
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
mountForPath,
type DiskFilesystem,
} from "../../src/frontend/disk-info";
function fs(mount: string): DiskFilesystem {
return {
filesystem: mount,
type: "ext4",
mount,
percent: 10,
usedHuman: "1G",
totalHuman: "10G",
availableHuman: "9G",
usedBytes: 1,
totalBytes: 10,
availableBytes: 9,
};
}
describe("mountForPath", () => {
const list = [fs("/"), fs("/volume1"), fs("/volume10")];
it("picks the deepest mount that holds the path", () => {
expect(mountForPath(list, "/volume1/share")?.mount).toBe("/volume1");
expect(mountForPath(list, "/volume10")?.mount).toBe("/volume10");
expect(mountForPath(list, "/etc")?.mount).toBe("/");
});
it("returns null without a path or mounts", () => {
expect(mountForPath(list, null)).toBeNull();
expect(mountForPath([], "/etc")).toBeNull();
});
});
@@ -0,0 +1,53 @@
import { describe, it, expect } from "vitest";
import {
formatFileSize,
runWithConcurrency,
} from "../../src/frontend/file-manager-utils.js";
describe("formatFileSize", () => {
it("returns a dash for undefined or null", () => {
expect(formatFileSize(undefined)).toBe("-");
expect(formatFileSize(null as unknown as number)).toBe("-");
});
it("returns 0 B for zero", () => {
expect(formatFileSize(0)).toBe("0 B");
});
it("formats bytes without decimals", () => {
expect(formatFileSize(512)).toBe("512 B");
expect(formatFileSize(1023)).toBe("1023 B");
});
it("formats kilobytes with one decimal under 10", () => {
expect(formatFileSize(1024)).toBe("1.0 KB");
expect(formatFileSize(1536)).toBe("1.5 KB");
});
it("rounds to whole numbers at or above 10 units", () => {
expect(formatFileSize(10 * 1024)).toBe("10 KB");
expect(formatFileSize(1024 * 1024)).toBe("1.0 MB");
});
it("scales up to larger units", () => {
expect(formatFileSize(1024 * 1024 * 1024)).toBe("1.0 GB");
expect(formatFileSize(1024 * 1024 * 1024 * 1024)).toBe("1.0 TB");
});
it("limits concurrent work", async () => {
let active = 0;
let maxActive = 0;
const completed: number[] = [];
await runWithConcurrency([1, 2, 3, 4, 5], 2, async (item) => {
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setTimeout(resolve, 1));
completed.push(item);
active -= 1;
});
expect(maxActive).toBe(2);
expect(completed.sort()).toEqual([1, 2, 3, 4, 5]);
});
});
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { resolveFilePreviewKind } from "../../src/frontend/file-preview-preload";
describe("resolveFilePreviewKind", () => {
it.each([
["photo.webp", "image"],
["movie.webm", "video"],
["voice.m4a", "audio"],
["README.md", "markdown"],
["manual.pdf", "pdf"],
["notes.txt", "text"],
["app.tsx", "code"],
["Dockerfile", "code"],
["LICENSE", "unknown"],
] as const)("classifies %s as %s", (filename, expected) => {
expect(resolveFilePreviewKind(filename)).toBe(expected);
});
});
@@ -0,0 +1,92 @@
import { describe, it, expect, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useDragAndDrop } from "../../../src/frontend/hooks/useDragAndDrop.js";
function makeEntry(name: string, isDirectory: boolean) {
return { name, isDirectory, isFile: !isDirectory } as FileSystemEntry;
}
function makeDropEvent(entries: FileSystemEntry[], files: File[] = []) {
const items = entries.map((entry) => ({
webkitGetAsEntry: () => entry,
}));
const dataTransfer = {
items,
files: Object.assign(files, { item: (i: number) => files[i] }),
};
return {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
dataTransfer,
} as unknown as React.DragEvent;
}
describe("useDragAndDrop", () => {
it("hands directory entries to onItemsDropped", () => {
const onItemsDropped = vi.fn();
const onFilesDropped = vi.fn();
const { result } = renderHook(() =>
useDragAndDrop({ onFilesDropped, onItemsDropped }),
);
const dir = makeEntry("myfolder", true);
act(() => result.current.dragHandlers.onDrop(makeDropEvent([dir])));
expect(onItemsDropped).toHaveBeenCalledWith([dir]);
expect(onFilesDropped).not.toHaveBeenCalled();
});
it("reads entries before state updates clear dataTransfer", () => {
const onItemsDropped = vi.fn();
const { result } = renderHook(() =>
useDragAndDrop({ onFilesDropped: vi.fn(), onItemsDropped }),
);
const dir = makeEntry("myfolder", true);
const event = makeDropEvent([dir]);
// Mimic the browser neutering dataTransfer once the handler unwinds.
act(() => {
result.current.dragHandlers.onDrop(event);
(event.dataTransfer as unknown as { items: unknown[] }).items = [];
});
expect(onItemsDropped).toHaveBeenCalledWith([dir]);
});
it("falls back to plain file upload when no directory is dropped", () => {
const onFilesDropped = vi.fn();
const onItemsDropped = vi.fn();
const { result } = renderHook(() =>
useDragAndDrop({ onFilesDropped, onItemsDropped }),
);
const file = new File(["hi"], "a.txt");
act(() =>
result.current.dragHandlers.onDrop(
makeDropEvent([makeEntry("a.txt", false)], [file]),
),
);
expect(onItemsDropped).not.toHaveBeenCalled();
expect(onFilesDropped).toHaveBeenCalled();
});
it("rejects files over the size limit", () => {
const onError = vi.fn();
const onFilesDropped = vi.fn();
const { result } = renderHook(() =>
useDragAndDrop({ onFilesDropped, onError, maxFileSize: 1 }),
);
const big = new File(["x"], "big.bin");
Object.defineProperty(big, "size", { value: 5 * 1024 * 1024 });
act(() => result.current.dragHandlers.onDrop(makeDropEvent([], [big])));
expect(onFilesDropped).not.toHaveBeenCalled();
expect(onError).toHaveBeenCalled();
});
});
@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useFileSelection } from "../../../src/frontend/hooks/useFileSelection.js";
type FileItem = {
name: string;
type: "file" | "directory" | "link";
path: string;
};
const f = (name: string): FileItem => ({
name,
type: "file",
path: `/dir/${name}`,
});
describe("useFileSelection", () => {
it("single-selects, replacing prior selection", () => {
const { result } = renderHook(() => useFileSelection());
act(() => result.current.selectFile(f("a")));
act(() => result.current.selectFile(f("b")));
expect(result.current.selectedFiles.map((x) => x.name)).toEqual(["b"]);
expect(result.current.getSelectedCount()).toBe(1);
});
it("multi-selects and toggles off on repeat", () => {
const { result } = renderHook(() => useFileSelection());
act(() => result.current.selectFile(f("a"), true));
act(() => result.current.selectFile(f("b"), true));
expect(result.current.getSelectedCount()).toBe(2);
act(() => result.current.selectFile(f("a"), true));
expect(result.current.selectedFiles.map((x) => x.name)).toEqual(["b"]);
});
it("reports isSelected by path", () => {
const { result } = renderHook(() => useFileSelection());
act(() => result.current.selectFile(f("a")));
expect(result.current.isSelected(f("a"))).toBe(true);
expect(result.current.isSelected(f("z"))).toBe(false);
});
it("selects a contiguous range regardless of direction", () => {
const { result } = renderHook(() => useFileSelection());
const files = [f("a"), f("b"), f("c"), f("d")];
act(() => result.current.selectRange(files, files[3], files[1]));
expect(result.current.selectedFiles.map((x) => x.name)).toEqual([
"b",
"c",
"d",
]);
});
it("selects all and clears", () => {
const { result } = renderHook(() => useFileSelection());
const files = [f("a"), f("b")];
act(() => result.current.selectAll(files));
expect(result.current.getSelectedCount()).toBe(2);
act(() => result.current.clearSelection());
expect(result.current.getSelectedCount()).toBe(0);
});
it("toggleSelection adds then removes", () => {
const { result } = renderHook(() => useFileSelection());
act(() => result.current.toggleSelection(f("a")));
expect(result.current.isSelected(f("a"))).toBe(true);
act(() => result.current.toggleSelection(f("a")));
expect(result.current.isSelected(f("a"))).toBe(false);
});
});
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { usePaneWidth } from "../../../src/frontend/hooks/usePaneWidth";
const KEY = "test:pane-width";
function mouse(type: string, clientX: number) {
document.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true }));
}
const opts = { storageKey: KEY, defaultWidth: 224, minWidth: 160 };
describe("usePaneWidth", () => {
beforeEach(() => localStorage.clear());
it("starts at the default and restores a stored width", () => {
expect(renderHook(() => usePaneWidth(opts)).result.current.width).toBe(224);
localStorage.setItem(KEY, "300");
expect(renderHook(() => usePaneWidth(opts)).result.current.width).toBe(300);
localStorage.setItem(KEY, "10"); // below min → ignored
expect(renderHook(() => usePaneWidth(opts)).result.current.width).toBe(224);
});
it("follows the drag, clamps to the minimum, and persists on release", () => {
const { result } = renderHook(() => usePaneWidth(opts));
act(() => {
result.current.startResize({
clientX: 400,
preventDefault() {},
stopPropagation() {},
} as unknown as React.MouseEvent);
});
expect(result.current.isResizing).toBe(true);
act(() => mouse("mousemove", 460));
expect(result.current.width).toBe(284);
act(() => mouse("mousemove", 100));
expect(result.current.width).toBe(160);
act(() => mouse("mouseup", 100));
expect(result.current.isResizing).toBe(false);
expect(localStorage.getItem(KEY)).toBe("160");
});
it("resets to the default", () => {
localStorage.setItem(KEY, "300");
const { result } = renderHook(() => usePaneWidth(opts));
act(() => result.current.resetWidth());
expect(result.current.width).toBe(224);
expect(localStorage.getItem(KEY)).toBe("224");
});
});
@@ -0,0 +1,103 @@
import { describe, it, expect, beforeEach } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { useResizableColumns } from "../../../src/frontend/hooks/useResizableColumns";
const columns = [
{ key: "modified", labelKey: "m", defaultWidth: 120, minWidth: 70 },
{ key: "size", labelKey: "s", defaultWidth: 80, minWidth: 56, maxWidth: 200 },
];
const STORAGE_KEY = "test:columns";
function mouse(type: string, clientX: number) {
document.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true }));
}
describe("useResizableColumns", () => {
beforeEach(() => localStorage.clear());
it("starts from defaults and builds a grid template with a flexible name column", () => {
const { result } = renderHook(() =>
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
);
expect(result.current.gridTemplateColumns).toBe(
"minmax(min(240px, 50%), 1fr) minmax(0, 120px) minmax(0, 80px)",
);
});
it("widens a column when its left-edge handle is dragged left and persists", () => {
const { result } = renderHook(() =>
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
);
const handle = result.current.getHandleProps("size");
act(() => {
handle.onMouseDown({
clientX: 500,
preventDefault() {},
stopPropagation() {},
} as unknown as React.MouseEvent);
});
expect(result.current.resizingKey).toBe("size");
act(() => mouse("mousemove", 460));
expect(result.current.widths.size).toBe(120);
act(() => mouse("mouseup", 460));
expect(result.current.resizingKey).toBeNull();
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual({
modified: 120,
size: 120,
});
});
it("clamps to min/max and restores stored widths", () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ modified: 10, size: 9999, junk: "x" }),
);
const { result } = renderHook(() =>
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
);
expect(result.current.widths).toEqual({ modified: 70, size: 200 });
});
it("hides and shows columns, dropping them from the template", () => {
const { result } = renderHook(() =>
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
);
act(() => result.current.toggleColumn("size"));
expect(result.current.isVisible("size")).toBe(false);
expect(result.current.gridTemplateColumns).toBe(
"minmax(min(240px, 50%), 1fr) minmax(0, 120px)",
);
expect(result.current.visibleColumns.map((c) => c.key)).toEqual([
"modified",
]);
expect(JSON.parse(localStorage.getItem(`${STORAGE_KEY}:hidden`)!)).toEqual([
"size",
]);
// A fresh mount restores the hidden set; toggling again shows it.
const remount = renderHook(() =>
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
);
expect(remount.result.current.isVisible("size")).toBe(false);
act(() => remount.result.current.toggleColumn("size"));
expect(remount.result.current.gridTemplateColumns).toBe(
"minmax(min(240px, 50%), 1fr) minmax(0, 120px) minmax(0, 80px)",
);
});
it("resets a column to its default on double click", () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ modified: 150 }));
const { result } = renderHook(() =>
useResizableColumns({ storageKey: STORAGE_KEY, columns }),
);
expect(result.current.widths.modified).toBe(150);
act(() => {
result.current.getHandleProps("modified").onDoubleClick({
preventDefault() {},
stopPropagation() {},
} as unknown as React.MouseEvent);
});
expect(result.current.widths.modified).toBe(120);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!).modified).toBe(120);
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from "vitest";
import {
getCachedFileContent,
invalidateCachedFileContent,
shouldPrefetchFileContent,
} from "../../../src/frontend/lib/file-content-request-cache";
describe("file content request cache", () => {
it("coalesces reads and reloads after invalidation", async () => {
const loader = vi.fn(async () => ({ content: "hello", path: "/note" }));
await Promise.all([
getCachedFileContent("session", "/note", loader),
getCachedFileContent("session", "/note", loader),
]);
expect(loader).toHaveBeenCalledOnce();
invalidateCachedFileContent("session", "/note");
await getCachedFileContent("session", "/note", loader);
expect(loader).toHaveBeenCalledTimes(2);
});
it("only prefetches known small files on an unconstrained network", () => {
expect(
shouldPrefetchFileContent(
{ name: "note", path: "/note", type: "file", size: 512 * 1024 },
512 * 1024,
),
).toBe(true);
expect(
shouldPrefetchFileContent(
{ name: "large", path: "/large", type: "file", size: 512 * 1024 + 1 },
512 * 1024,
),
).toBe(false);
expect(
shouldPrefetchFileContent(
{ name: "unknown", path: "/unknown", type: "file" },
512 * 1024,
),
).toBe(false);
expect(
shouldPrefetchFileContent(
{ name: "note", path: "/note", type: "file", size: 100 },
0,
),
).toBe(false);
});
});
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createKeyedRequestCache } from "../../../src/frontend/lib/keyed-request-cache";
afterEach(() => vi.useRealTimers());
describe("createKeyedRequestCache", () => {
it("coalesces requests per key without mixing keys", async () => {
let resolve!: (value: string) => void;
const loader = vi.fn(() => new Promise<string>((done) => (resolve = done)));
const cache = createKeyedRequestCache<string>(5_000);
const first = cache.get("host:1", loader);
const duplicate = cache.get("host:1", loader);
const other = cache.get("host:2", async () => "other");
resolve("shared");
await expect(Promise.all([first, duplicate, other])).resolves.toEqual([
"shared",
"shared",
"other",
]);
expect(loader).toHaveBeenCalledOnce();
});
it("can show stale data immediately while forcing one refresh", async () => {
vi.useFakeTimers();
const cache = createKeyedRequestCache<string>(1_000);
await cache.get("directory", async () => "old");
await vi.advanceTimersByTimeAsync(2_000);
expect(cache.peek("directory", 30_000)).toBe("old");
const loader = vi.fn(async () => "fresh");
const [a, b] = await Promise.all([
cache.get("directory", loader, { force: true }),
cache.get("directory", loader, { force: true }),
]);
expect([a, b]).toEqual(["fresh", "fresh"]);
expect(loader).toHaveBeenCalledOnce();
});
it("evicts the least recently used entry when bounded", async () => {
const cache = createKeyedRequestCache<number>(10_000, 2);
await cache.get("a", async () => 1);
await cache.get("b", async () => 2);
expect(cache.peek("a")).toBe(1);
await cache.get("c", async () => 3);
expect(cache.peek("a")).toBe(1);
expect(cache.peek("b")).toBeNull();
expect(cache.peek("c")).toBe(3);
});
it("keeps stale data when a refresh fails", async () => {
const cache = createKeyedRequestCache<string>(1_000);
await cache.get("key", async () => "safe");
await expect(
cache.get(
"key",
async () => {
throw new Error("offline");
},
{ force: true },
),
).rejects.toThrow("offline");
expect(cache.peek("key")).toBe("safe");
});
it("does not let an invalidated request overwrite newer data", async () => {
let resolveOld!: (value: string) => void;
const cache = createKeyedRequestCache<string>(10_000);
const oldRequest = cache.get(
"key",
() => new Promise<string>((resolve) => (resolveOld = resolve)),
);
cache.set("key", "optimistic");
resolveOld("old");
await oldRequest;
expect(cache.peek("key")).toBe("optimistic");
});
});
@@ -0,0 +1,386 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
LOCAL_FILES_DRAG_MIME,
REMOTE_FILES_DRAG_MIME,
UnsafeLocalNameError,
assertSafeLocalComponent,
beginRemoteFilesDrag,
buildLocalDestination,
clampTransferConcurrency,
describeLocalKind,
runWithConcurrency,
takeLegacyTransferConcurrency,
DEFAULT_TRANSFER_CONCURRENCY,
TRANSFER_CONCURRENCY_STORAGE_KEY,
formatLocalModified,
isLocalFilesDrag,
isRemoteFilesDrag,
joinLocalPath,
joinRemotePath,
parseInternalFilesDragPayload,
parseLocalFilesDragPayload,
planRemoteDirectories,
remoteBaseName,
remoteDirForRelativePath,
serializeLocalFilesDragPayload,
sortLocalEntries,
} from "../../src/frontend/local-transfer-utils";
import type { LocalFileEntry } from "@termix/plugin-sdk/frontend";
describe("drag payloads", () => {
it("round-trips local file payloads", () => {
const raw = serializeLocalFilesDragPayload(["/Users/max/a.txt", "/tmp/b"]);
expect(parseLocalFilesDragPayload(raw)).toEqual([
"/Users/max/a.txt",
"/tmp/b",
]);
});
it("rejects foreign or malformed payloads", () => {
expect(parseLocalFilesDragPayload(null)).toBeNull();
expect(parseLocalFilesDragPayload("not json")).toBeNull();
expect(
parseLocalFilesDragPayload(
JSON.stringify({ type: "internal_files", files: ["/x"] }),
),
).toBeNull();
expect(
parseLocalFilesDragPayload(
JSON.stringify({ type: "local_files", paths: [] }),
),
).toBeNull();
expect(
parseLocalFilesDragPayload(
JSON.stringify({ type: "local_files", paths: [1, "", "/ok"] }),
),
).toEqual(["/ok"]);
});
it("parses the remote grid's internal payload", () => {
expect(
parseInternalFilesDragPayload(
JSON.stringify({ type: "internal_files", files: ["/srv/a", "/srv/b"] }),
),
).toEqual(["/srv/a", "/srv/b"]);
expect(
parseInternalFilesDragPayload(
JSON.stringify({ type: "local_files", paths: ["/x"] }),
),
).toBeNull();
});
it("recognises drag origins from dataTransfer types", () => {
expect(
isLocalFilesDrag({ types: [LOCAL_FILES_DRAG_MIME, "text/plain"] }),
).toBe(true);
expect(isLocalFilesDrag({ types: ["Files"] })).toBe(false);
expect(isRemoteFilesDrag({ types: [REMOTE_FILES_DRAG_MIME] })).toBe(true);
expect(isRemoteFilesDrag({ types: ["text/plain"] })).toBe(false);
expect(isRemoteFilesDrag(null)).toBe(false);
});
});
describe("path helpers", () => {
it("joins remote paths without duplicate slashes", () => {
expect(joinRemotePath("/", "a", "b")).toBe("/a/b");
expect(joinRemotePath("/home/ubuntu/", "/proj/", "x.txt")).toBe(
"/home/ubuntu/proj/x.txt",
);
expect(joinRemotePath("/home", "")).toBe("/home");
});
it("joins local paths with the platform separator", () => {
expect(joinLocalPath("/Users/max", "a.txt", "/")).toBe("/Users/max/a.txt");
expect(joinLocalPath("/Users/max/", "a.txt", "/")).toBe("/Users/max/a.txt");
expect(joinLocalPath("/", "a.txt", "/")).toBe("/a.txt");
expect(joinLocalPath("C:\\Users\\max", "a.txt", "\\")).toBe(
"C:\\Users\\max\\a.txt",
);
});
it("derives base names", () => {
expect(remoteBaseName("/srv/app/file.log")).toBe("file.log");
expect(remoteBaseName("/srv/app/")).toBe("app");
expect(remoteBaseName("/")).toBe("/");
});
it("maps relative paths to their remote directory", () => {
expect(remoteDirForRelativePath("/dst", "file.txt")).toBe("/dst");
expect(remoteDirForRelativePath("/dst", "proj/src/main.rs")).toBe(
"/dst/proj/src",
);
});
});
describe("download destination safety", () => {
const WIN = "\\";
const POSIX = "/";
it("keeps ordinary nested folders under the selected directory (Windows)", () => {
expect(
buildLocalDestination(
"C:\\Downloads\\selected",
"docs/2026/report.pdf",
WIN,
),
).toBe("C:\\Downloads\\selected\\docs\\2026\\report.pdf");
expect(
buildLocalDestination("C:\\Downloads\\selected\\", "a.txt", WIN),
).toBe("C:\\Downloads\\selected\\a.txt");
});
it("rejects backslash traversal in a POSIX file name on Windows", () => {
// The reviewer's reproduction: "..\\outside.txt" is a legal POSIX name.
expect(() =>
buildLocalDestination("C:\\Downloads\\selected", "..\\outside.txt", WIN),
).toThrow(UnsafeLocalNameError);
expect(() =>
buildLocalDestination(
"C:\\Downloads\\selected",
"sub/..\\..\\x.txt",
WIN,
),
).toThrow(UnsafeLocalNameError);
expect(() =>
buildLocalDestination("C:\\Downloads\\selected", "dir\\file.txt", WIN),
).toThrow(UnsafeLocalNameError);
});
it("rejects absolute and drive-qualified names on Windows", () => {
for (const name of [
"C:\\Windows\\evil.dll",
"C:evil.txt",
"D:",
"\\\\server\\share\\x",
"\\absolute.txt",
]) {
expect(() =>
buildLocalDestination("C:\\Downloads\\selected", name, WIN),
).toThrow(UnsafeLocalNameError);
}
});
it("rejects names Windows cannot store: reserved devices, trailing dots/spaces, control chars", () => {
for (const name of [
"CON",
"nul.txt",
"COM1",
"report.",
"report ",
"a\u0007b",
"q?.txt",
"a|b",
]) {
expect(() => assertSafeLocalComponent(name, WIN)).toThrow(
UnsafeLocalNameError,
);
}
expect(assertSafeLocalComponent("console.log", WIN)).toBe("console.log");
expect(assertSafeLocalComponent("nulled.txt", WIN)).toBe("nulled.txt");
});
it("rejects traversal and separators on every platform, but allows POSIX-legal backslashes on POSIX", () => {
for (const rel of ["..", "../x", "a/../../x", "./x", "a\0b", ""]) {
expect(() => buildLocalDestination("/home/max/dl", rel, POSIX)).toThrow(
UnsafeLocalNameError,
);
}
// On macOS/Linux a backslash is just a character in a file name and the
// result is still inside the selected folder.
expect(
buildLocalDestination("/home/max/dl", "..\\outside.txt", POSIX),
).toBe("/home/max/dl/..\\outside.txt");
expect(buildLocalDestination("/", "etc/hosts", POSIX)).toBe("/etc/hosts");
});
});
describe("planRemoteDirectories", () => {
it("lists every ancestor once, shallowest first", () => {
const dirs = planRemoteDirectories(
["proj/src/main.rs", "proj/README.md", "proj/src/lib/mod.rs", "top.txt"],
["proj/empty", "other/nested/leaf"],
);
expect(dirs).toEqual([
"other",
"proj",
"other/nested",
"proj/empty",
"proj/src",
"other/nested/leaf",
"proj/src/lib",
]);
});
it("returns nothing for flat file drops", () => {
expect(planRemoteDirectories(["a.txt", "b.txt"])).toEqual([]);
});
});
describe("remote rows dragged out of the grid", () => {
it("allows both a move (within the grid) and a copy (download onto the local pane)", () => {
const store: Record<string, string> = {};
const dataTransfer: Pick<DataTransfer, "effectAllowed" | "setData"> = {
effectAllowed: "uninitialized",
setData: (type: string, value: string) => {
store[type] = value;
},
};
beginRemoteFilesDrag(dataTransfer, ["/srv/a.txt", "/srv/dir"]);
// Chromium silently drops nothing when dropEffect ("copy" on the local
// pane) is not part of effectAllowed, so "move" alone breaks downloads.
expect(dataTransfer.effectAllowed).toBe("copyMove");
expect(isRemoteFilesDrag({ types: Object.keys(store) })).toBe(true);
expect(parseInternalFilesDragPayload(store["text/plain"])).toEqual([
"/srv/a.txt",
"/srv/dir",
]);
});
});
describe("local entry presentation", () => {
const entry = (
name: string,
type: LocalFileEntry["type"],
size = 0,
modifiedTimestamp = 0,
): LocalFileEntry => ({
name,
path: `/x/${name}`,
type,
size,
modifiedTimestamp,
hidden: name.startsWith("."),
});
it("formats modified times like the remote grid (ls -l style)", () => {
const now = new Date(2026, 8, 12, 10, 0); // Sep 12 2026
expect(
formatLocalModified(new Date(2026, 8, 11, 16, 25).getTime(), now),
).toBe("Sep 11 16:25");
// Single-digit days are space-padded, minutes zero-padded.
expect(formatLocalModified(new Date(2026, 7, 7, 9, 5).getTime(), now)).toBe(
"Aug 7 09:05",
);
// Older than six months: year instead of time, two spaces like ls.
expect(
formatLocalModified(new Date(2025, 11, 24, 18, 30).getTime(), now),
).toBe("Dec 24 2025");
expect(formatLocalModified(undefined, now)).toBe("--");
expect(formatLocalModified(Number.NaN, now)).toBe("--");
});
it("describes kinds", () => {
expect(describeLocalKind(entry("docs", "directory"))).toBe("folder");
expect(describeLocalKind(entry("archive.tar.GZ", "file"))).toBe("gz");
expect(describeLocalKind(entry(".env", "file"))).toBe("file");
expect(describeLocalKind(entry("Makefile", "file"))).toBe("file");
expect(describeLocalKind(entry("ln", "link"))).toBe("link");
});
it("sorts folders first, then by the chosen field", () => {
const entries = [
entry("b.txt", "file", 20, 2),
entry("zeta", "directory", 0, 1),
entry("a.txt", "file", 10, 3),
entry("alpha", "directory", 0, 4),
];
expect(sortLocalEntries(entries, "name", "asc").map((e) => e.name)).toEqual(
["alpha", "zeta", "a.txt", "b.txt"],
);
expect(
sortLocalEntries(entries, "size", "desc").map((e) => e.name),
).toEqual(["zeta", "alpha", "b.txt", "a.txt"]);
expect(
sortLocalEntries(entries, "modified", "asc").map((e) => e.name),
).toEqual(["zeta", "alpha", "b.txt", "a.txt"]);
});
});
describe("parallel transfers", () => {
beforeEach(() => localStorage.clear());
it("clamps the concurrency preference", () => {
expect(clampTransferConcurrency(0)).toBe(1);
expect(clampTransferConcurrency(99)).toBe(8);
expect(clampTransferConcurrency("3.7")).toBe(3);
expect(clampTransferConcurrency("nope")).toBe(DEFAULT_TRANSFER_CONCURRENCY);
});
it("reads the value a browser kept before 2.9.0 once, then forgets it", () => {
expect(takeLegacyTransferConcurrency()).toBeNull();
localStorage.setItem(TRANSFER_CONCURRENCY_STORAGE_KEY, "12");
expect(takeLegacyTransferConcurrency()).toBe(8);
expect(localStorage.getItem(TRANSFER_CONCURRENCY_STORAGE_KEY)).toBeNull();
expect(takeLegacyTransferConcurrency()).toBeNull();
});
const deferred = () => {
let resolve!: () => void;
const promise = new Promise<void>((r) => (resolve = r));
return { promise, resolve };
};
it("never runs more than `limit` workers at once and processes everything", async () => {
const gates = Array.from({ length: 6 }, deferred);
const started: number[] = [];
let active = 0;
let peak = 0;
const run = runWithConcurrency([0, 1, 2, 3, 4, 5], 3, async (i) => {
started.push(i);
active += 1;
peak = Math.max(peak, active);
await gates[i].promise;
active -= 1;
});
await Promise.resolve();
expect(started).toEqual([0, 1, 2]); // exactly `limit` dispatched
gates[1].resolve();
await new Promise((r) => setTimeout(r, 0));
expect(started).toEqual([0, 1, 2, 3]); // a free lane picks up the next item
for (const g of gates) g.resolve();
await run;
expect(started).toEqual([0, 1, 2, 3, 4, 5]);
expect(peak).toBe(3);
});
it("stops dispatching once asked to, letting in-flight items finish", async () => {
let stop = false;
const done: number[] = [];
await runWithConcurrency(
[1, 2, 3, 4, 5],
2,
async (i) => {
await new Promise((r) => setTimeout(r, 1));
done.push(i);
if (i === 2) stop = true;
},
() => stop,
);
expect(done.length).toBeLessThan(5);
expect(done).toContain(1);
expect(done).toContain(2);
});
it("does not reject the batch when a single item fails (the worker reports it)", async () => {
const failed: number[] = [];
await runWithConcurrency([1, 2, 3], 2, async (i) => {
try {
if (i === 2) throw new Error("boom");
} catch {
failed.push(i);
}
});
expect(failed).toEqual([2]);
});
it("handles an empty list and a limit larger than the list", async () => {
let calls = 0;
await runWithConcurrency([], 4, async () => {
calls += 1;
});
await runWithConcurrency([1], 8, async () => {
calls += 1;
});
expect(calls).toBe(1);
});
});
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { FileItem } from "../../src/frontend/host-types";
import {
addOptimisticItem,
childPath,
removePaths,
renameOptimisticItem,
restoreItems,
} from "../../src/frontend/optimistic-file-list";
const files: FileItem[] = [
{ name: "alpha.txt", path: "/work/alpha.txt", type: "file", size: 10 },
{ name: "docs", path: "/work/docs", type: "directory" },
];
describe("optimistic file list updates", () => {
it("builds child paths without duplicate separators", () => {
expect(childPath("/", "file.txt")).toBe("/file.txt");
expect(childPath("/work/", "file.txt")).toBe("/work/file.txt");
});
it("adds and rolls back a provisional item", () => {
const added = addOptimisticItem(files, "/work", "new.txt", "file", 0);
expect(added.at(-1)).toMatchObject({
name: "new.txt",
path: "/work/new.txt",
type: "file",
size: 0,
});
expect(removePaths(added, new Set(["/work/new.txt"]))).toEqual(files);
});
it("renames immediately and can reverse the same item", () => {
const renamed = renameOptimisticItem(files, "/work/alpha.txt", "beta.txt");
expect(renamed[0]).toMatchObject({
name: "beta.txt",
path: "/work/beta.txt",
size: 10,
});
expect(
renameOptimisticItem(renamed, "/work/beta.txt", "alpha.txt"),
).toEqual(files);
});
it("restores only failed deletions without duplicating survivors", () => {
const remaining = removePaths(files, new Set(["/work/docs"]));
expect(restoreItems(remaining, [files[1], files[0]])).toEqual(files);
});
});
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import {
hasSameHostTransferConflict,
joinRemotePath,
normalizeRemoteDir,
} from "../../src/frontend/sftp-transfer-utils";
describe("sftp transfer utilities", () => {
it("normalizes and joins remote paths", () => {
expect(normalizeRemoteDir(" /home/user// ")).toBe("/home/user");
expect(joinRemotePath("/home/user/", "docs/readme.md")).toBe(
"/home/user/docs/readme.md",
);
expect(joinRemotePath("/", "tmp/file.txt")).toBe("/tmp/file.txt");
expect(joinRemotePath("home/user", "logs")).toBe("/home/user/logs");
});
it("detects same-host destination conflicts", () => {
expect(hasSameHostTransferConflict(["/opt/app"], "/opt/app")).toBe(true);
expect(hasSameHostTransferConflict(["/opt/app"], "/opt/app/logs")).toBe(
true,
);
expect(hasSameHostTransferConflict(["/opt/app"], "/opt/releases")).toBe(
false,
);
});
});
@@ -0,0 +1,40 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const success = vi.fn();
vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => success(...args),
},
}));
vi.mock("../../src/frontend/api/transfer-api", () => ({}));
vi.mock("../../src/frontend/transferNotificationStore.ts", () => ({
markTransferNotified: vi.fn(),
}));
import { showTransferCompletionToast } from "../../src/frontend/transferProgressMonitor.tsx";
describe("transfer completion toast", () => {
beforeEach(() => {
success.mockReset();
});
it("clears the infinite duration inherited from the progress toast", () => {
showTransferCompletionToast(
{
transferId: "transfer-1",
status: "success",
phase: "verifying",
},
((key: string) => key) as never,
"progress-toast",
);
expect(success).toHaveBeenCalledWith("transfer.transferSuccess", {
id: "progress-toast",
description: undefined,
className: "!pr-10 !pl-4 transfer-progress-toast",
duration: undefined,
});
});
});