release-2.7.0 (#1264)

* feat: redesign host/credential sidebars with synced preferences and manual drag-to-reorder

* chore: run format

* chore(deps-dev): bump @types/pg in the dev-patch-updates group (#1162)

Bumps the dev-patch-updates group with 1 update: [@types/pg](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/pg).


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

---
updated-dependencies:
- dependency-name: "@types/pg"
  dependency-version: 8.20.3
  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-dev): bump the dev-minor-updates group with 4 updates (#1163)

Bumps the dev-minor-updates group with 4 updates: [react-hook-form](https://github.com/react-hook-form/react-hook-form), [react-icons](https://github.com/react-icons/react-icons), [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `react-hook-form` from 7.79.0 to 7.84.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.79.0...v7.84.0)

Updates `react-icons` from 5.6.0 to 5.7.0
- [Release notes](https://github.com/react-icons/react-icons/releases)
- [Commits](https://github.com/react-icons/react-icons/compare/v5.6.0...v5.7.0)

Updates `typescript-eslint` from 8.61.1 to 8.66.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.66.0/packages/typescript-eslint)

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

---
updated-dependencies:
- dependency-name: react-hook-form
  dependency-version: 7.84.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: react-icons
  dependency-version: 5.7.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript-eslint
  dependency-version: 8.66.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: vite
  dependency-version: 8.2.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-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 3 updates (#1164)

Bumps the prod-patch-updates group with 3 updates: [jose](https://github.com/panva/jose), [js-yaml](https://github.com/nodeca/js-yaml) and [nanoid](https://github.com/ai/nanoid).


Updates `jose` from 6.2.7 to 6.2.8
- [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.7...v6.2.8)

Updates `js-yaml` from 5.2.2 to 5.2.3
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/5.2.2...5.2.3)

Updates `nanoid` from 6.0.0 to 6.0.1
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/6.0.0...6.0.1)

---
updated-dependencies:
- dependency-name: jose
  dependency-version: 6.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: js-yaml
  dependency-version: 5.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: nanoid
  dependency-version: 6.0.1
  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>

* chore(deps): bump undici in the prod-minor-updates group (#1165)

Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici).


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

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

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 major-updates group with 2 updates (#1166)

Bumps the major-updates group with 2 updates: [@types/better-sqlite3](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/better-sqlite3) and [jsdom](https://github.com/jsdom/jsdom).


Updates `@types/better-sqlite3` from 7.6.13 to 9.6.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/better-sqlite3)

Updates `jsdom` from 29.1.1 to 30.0.1
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v29.1.1...v30.0.1)

---
updated-dependencies:
- dependency-name: "@types/better-sqlite3"
  dependency-version: 9.6.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
- dependency-name: jsdom
  dependency-version: 30.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: major-updates
...

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

* fix: stop resyncAutoIncrement failing on tables without an id column (#1173)

The Postgres branch asked pg_get_serial_sequence(table, 'id') about every
table a fixture had inserted into. That function raises 42703 when the
column does not exist, rather than returning null, so any seed touching a
table keyed on something else took down the fixture.

host_sidebar_preferences is keyed on user_id and has no id at all, which
is why the Postgres job on dev-2.7.0 fails for every pull request.

Drive the lookup from information_schema so a missing id column yields no
row instead of an error. A text primary key still returns a null sequence
and is still skipped, as before.

* chore: install the git hooks that were already configured (#1174)

husky, lint-staged, commitlint and their config have been in the repo
since v1.8.0 (#429): .husky/pre-commit runs lint-staged, .husky/commit-msg
runs commitlint, the lint-staged globs are in package.json and the
commitlint rules in .commitlintrc.json.

None of it has ever run. husky only takes effect once it sets
core.hooksPath, and that happens in the prepare lifecycle script, which
the package did not define -- so every clone installed the tooling and
left the hooks unwired.

That is why formatting keeps failing in CI rather than locally: three of
the four open pull requests fail lint-and-build on prettier alone,
touching between one and five files each, and the check is the first place
anyone finds out.

prepare falls back to true so a checkout without a .git directory cannot
break installation. The Docker build passes --ignore-scripts, so it never
runs this at all.

Also pin the Prettier extension to the repo's own copy via
prettier.prettierPath, and let .vscode/settings.json out of .gitignore so
it applies to everyone. The extension bundles its own prettier otherwise,
which formats to a different version's rules than the one CI enforces.

* fix: derive the ssh_credentials rebuild from the live schema (#1172)

The startup rebuild that drops the old username NOT NULL constraint
restated the table's columns as a literal and then copied rows with
INSERT INTO temp SELECT <every live column>. The table has gained columns
since that literal was written — cert_public_key, pin, sort_order and
sync_id are all added by addColumnIfNotExists before the rebuild runs —
so the destination was narrower than the source. SQLite rejected the
INSERT on a column count mismatch, the error was swallowed as a warning,
and the constraint survived every restart.

Read the CREATE TABLE statement back from sqlite_master and rewrite just
the table name and the username constraint, so the replacement table
cannot fall behind the real one. Copy rows by explicit column name rather
than positionally, and replay the table's indexes, which DROP TABLE would
otherwise take with it along with the sync_id uniqueness.

* fix: make audit_logs.user_id nullable on fresh SQLite installs (#1171)

The audit trail is meant to outlive the account it belongs to: deleting a
user nulls user_id and keeps username for attribution. schema.ts, the
Drizzle migrations and AuditLogRepository.anonymizeByUserId were all
written against that, but the runtime bootstrap still created
user_id TEXT NOT NULL.

A second CREATE TABLE IF NOT EXISTS further down migrateSchema() had the
correct nullable column, but it can never run — the primary bootstrap has
already created the table, so IF NOT EXISTS is a no-op. Every fresh
install therefore got the old constraint, and user deletion failed with
"NOT NULL constraint failed: audit_logs.user_id" for any account that had
logged in at least once, via both the admin delete path and the OIDC
account-link cleanup.

Fix the primary bootstrap, and rebuild the table on existing databases
using the same pattern already used for ssh_credentials.username, since
SQLite cannot ALTER a column.

* fix: key the sync upsert on the row it just looked up (#1175)

A sync push locates the stored row twice -- once to decide insert vs
update, once to write it -- and the two lookups were spelled out
separately. Only the read knew about singleton entities; the write always
keyed on table.id.

userPreferences is the only singleton, and user_preferences is the one
synced table with no id column: its primary key is user_id. table.id was
therefore undefined, and drizzle emitted a comparison with nothing on its
left:

  ( = ? and "user_preferences"."user_id" = ?)

The insert branch was unaffected, so the first push of preferences
succeeded and every push after it -- the steady state -- failed with
SqliteError: near "=": syntax error. Preference sync never converged, and
both sides ship the same handler, so the desktop's embedded backend failed
identically.

Extract the lookup into locateSyncRow() and use it for the read, the
update and the tombstone delete, so the three cannot drift apart again.
The tombstone path already handled singletons correctly; it now shares the
one expression rather than keeping a third copy of it.

* fix: refuse an SSH connection whose host id resolves elsewhere (#1176)

A client identifies a host by the numeric row id of the database it is
displaying. With the desktop connection origin set to "Remote server",
that id is resolved against the sync server's ssh_data instead, and the
two autoincrement sequences need not line up -- they diverge as soon as
each side accumulates inserts and deletes in a different order.

resolveHostById() then returns whichever row owns that id here, and the
handler takes the address, the credentials, the jump hosts and the stored
host key from it. The session opens on a machine the user did not pick,
while the host list, host details and export all keep showing the right
one. Commands run on the wrong server, a host key mismatch is reported
for the wrong reason, and anything typed at the prompt goes to the wrong
place.

Compare the resolved address against the one the client sent, and refuse
when they disagree. Checking at the point the row is loaded covers every
use of it rather than each site separately. Addresses are compared with
brackets stripped and casing folded, so an IPv6 literal or a hostname
written differently is not treated as a different machine; when the
server has no address stored, the client's own details are used as
before.

This stops the wrong-machine session. It does not make delegated
connections work when the ids have drifted -- that needs the host to be
addressed by syncId across the boundary, which the connection protocol
does not currently carry.

* fix: refuse SFTP and Docker console on a mismatched host id too (#1177)

The wrong-machine guard added for SSH covered one of the paths that
resolve a client-supplied host id against this server's ssh_data. The
file manager and the Docker console take the same id from the same client
and dial whatever row owns it here.

The file manager then browses, edits and deletes files on that machine,
and the Docker console attaches to its daemon -- both while the UI shows
the host the user actually picked.

Reuse hostAddressMismatch at each point the row is loaded. The two file
manager sites sit inside "failed to resolve credentials, carry on"
handlers, so the refusal is a distinct error type those catches rethrow;
swallowing it would resume the connection this is meant to stop. The
Docker console reports over its socket, as it does for every other
refusal.

The user-facing wording now lives next to the check instead of being
written out at each site.

Still uncovered, and not fixable this way: file-manager's transfer
session, jump-host-chain and the proxmox routes resolve an id with no
client-supplied address to compare it against. Those need the host to be
addressed by syncId across the boundary.

* feat: address hosts by syncId when a connection is delegated (#1178)

A numeric host id belongs to the database that produced it. The desktop
app lists hosts from its embedded database and names them by row id, so
when a connection is delegated to a sync server that id is resolved
against a different table, whose autoincrement sequence has no reason to
agree. The row it lands on is a different machine, and it supplies the
address, the credentials, the jump hosts and the stored host key.

#1176 and #1177 made that refuse rather than connect. Refusing is right,
but it leaves "Remote server" unusable once the ids have drifted, which
is the state the reporter was in.

syncId already names a host identically on both sides -- remote sync
relies on it, ssh_data.sync_id is unique, and the API already returns it.
It just never reached the backend: hostToSSHHost() builds its result field
by field and dropped it.

Carry it through, and resolve with it when it is present:

  resolveHostBySyncId(syncId, userId)   // translate, then reuse
    -> findHostIdBySyncId(syncId)       // this database's own row id
    -> resolveHostById(hostId, userId)  // permissions, decryption, audit

The translation is deliberately not scoped to a user -- sync_id is unique
across the table and a shared host belongs to someone else -- so access
stays with the permission check in the id-based path, which the new tests
cover.

An unknown syncId resolves to nothing rather than falling back to the
numeric id: an unknown host is precisely where guessing picks the wrong
machine. Clients that send no syncId are unchanged, address comparison
included, so an older desktop keeps its safety net instead of breaking.

* fix(homepage): make the System Overview update indicator able to fire (#1168)

The widget's "Update available" row and orange version text were unreachable,
for two independent reasons that each alone would have been enough.

It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return
early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch,
no remote version, nothing to compare. It then read `info.updateAvailable`, a
field the route does not return in either mode; the success response carries
status, localVersion, version, remoteVersion, latest_release, cached and
cache_age. `Boolean(undefined)` is false, always. The read type-checked only
because `getVersionInfo()` is declared as `Record<string, unknown>`, so a
property name that does not exist is indistinguishable from one that does.

Let the endpoint do the comparison and read `status === "requires_update"`,
which is what the dashboard stats bar and the profile panel badge already do.

The row's label was `homepage.overviewUpdate`, whose English string is "Up to
date" -- as the label of an update-available row it read "Up to date / Update
available". Nobody has seen that, because the row has never rendered; fixing
the indicator without the label would have shipped it. Give it its own key.
That leaves `homepage.overviewUpdate` unused; it is left in place rather than
removed, since it would be the natural value for an always-visible row and that
is a product decision, not part of this fix.

* fix: capture real client IP for SSH login alerts behind reverse proxy (#1169)

* fix: capture real client IP for SSH login alerts behind reverse proxy

The WebSocket terminal handler used req.socket.remoteAddress for the
"user logged in" alert message, which is the immediate TCP peer (the
reverse proxy) rather than the actual client IP forwarded via
X-Forwarded-For. This made trust-proxy config on Traefik irrelevant
since Termix never read the header for this code path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test: cover getClientIp forwarded-header and socket fallback paths

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix: keep already-shared hosts sharing their SSH authentication (#1179)

Sharing a host used to hand the owner's SSH authentication to the
recipient unconditionally. 2.6.1 put that behind ssh_data.share_ssh_auth,
added as NOT NULL DEFAULT 0.

Existing rows took the default, so every host shared before the upgrade
stopped supplying credentials the moment the column appeared. The snapshot
in collectProtocolSnapshots() is guarded by host.shareSshAuth, so nothing
was captured; resolveRecipientSharedHostAuthentication() then fell through
to "required" and the recipient got "No valid authentication method
provided" on a host that had worked the day before. Downgrading to 2.6.0
restored it, since that code has no such column to consult.

Backfill the flag for hosts that already appear in host_access. That is
where the previous behaviour was in effect and where the owner had already
agreed to share; hosts nobody has shared keep the new default and stay off
until their owner shares them.

Guarded by a settings key so it runs once. Without that, an owner who
turns sharing back off would have it turned on again by the next restart.

* fix: let a single credential disable 2FA again (#1180)

The disable dialog has one field, labelled "Enter TOTP code or password",
and its caller passes that value as disableTOTP(input) -- so it arrives as
`password` with `totp_code` undefined. That call has been unchanged since
v2.3.0.

2.5.1 changed the route to require both:

  if (!totp_code || (!userRecord.isOidc && !password)) -> 400

replacing `const credential = password || totp_code`. The first check has
rejected every attempt since, whatever the user typed, so nobody has been
able to turn 2FA off -- the client reports the generic "Failed to disable
2FA", which hides which check failed.

Take one credential again and try it as a TOTP code, a backup code, then
the account password. verifyTotpReauth still refuses the password itself,
so that comparison stays in the route; an OIDC user has no password hash
and reaches neither.

The backup-codes route has the same shape but no caller in the UI -- its
codes are returned when TOTP is enabled -- so it is left alone rather than
changed blind.

* fix: attach user-managed CA certificates over SFTP too (#1181)

opkssh-cert-auth.ts exports two helpers that end in the same
_applyCertToConnection: setupOPKSSHCertAuth, and setupCACertAuth for
user-managed CA-signed -cert.pub files. The file manager called the first
one twice and the second one never.

So a host whose key is paired with a CA-signed certificate authenticated
in a terminal and failed over SFTP, while OPKSSH certificates -- going
through the other helper -- worked in both. The file manager was not
missing certificate support in general; it was missing one of the two
paths into it.

The connection also never carried the certificate to begin with:
cert_public_key was not among the fields copied into resolvedCredentials,
so both places that build an SFTP connection now read it and attach it
where the private key is prepared -- the dedicated transfer session and
the main connect route.

An unusable certificate is logged and skipped rather than failing the
connection. The key alone may still be accepted, which is what happened
while this was not wired up at all, and turning that into a hard failure
would break setups that currently work.

Reported in #1160 with the call-site asymmetry already traced; the
reporter noted they could not confirm the link to their failure, having
moved off SSH CAs. The asymmetry is real either way and reproduces the
symptom exactly.

* fix: authenticate the desktop Docker console WebSocket (#1182)

The console WS opted out of the query token:

  buildOriginWsUrl({ ..., includeLocalJwt: false })

leaving it with no credential at all on the desktop. The browser
WebSocket API cannot set an Authorization header, and while Electron's
main process injects a remembered JWT cookie, it requires an exact origin
match -- the cookie belongs to the API origin (localhost:30001) while the
console connects to 127.0.0.1:30009, so nothing is attached.

The backend then closes the handshake with 1008 before it logs anything,
which is why the log has no docker-console entries while stats and logs
polling keep succeeding on the same host. The web build is unaffected: it
connects same-origin and its cookie is sent normally.

Drop the opt-out so the console carries the local JWT like the SSH
terminal does -- the same token, the same query parameter, and the
backend already reads it there.

Guacamole passes includeLocalJwt: false too, but rdp/vnc/telnet always
resolve to "remote", so that call never reaches the local branch.

* fix: use getClientIp in getRequestMeta for correct audit-log IPs (#1183)

* fix: capture real client IP for SSH login alerts behind reverse proxy

The WebSocket terminal handler used req.socket.remoteAddress for the
"user logged in" alert message, which is the immediate TCP peer (the
reverse proxy) rather than the actual client IP forwarded via
X-Forwarded-For. This made trust-proxy config on Traefik irrelevant
since Termix never read the header for this code path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test: cover getClientIp forwarded-header and socket fallback paths

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix: use getClientIp in getRequestMeta for correct audit-log IPs

getRequestMeta had near-duplicate, strictly worse forwarded-header
logic: the array branch didn't split/trim, there was no socket-peer
fallback, and it returned "" instead of "unknown". Delegate to
getClientIp so the audit trail gets the same correctness as the
terminal login-alert path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* feat: add terminal image handoff (#1170)

* chore: sync Crowdin translations

* fix(homepage): make the System Overview update indicator able to fire (#1168)

The widget's "Update available" row and orange version text were unreachable,
for two independent reasons that each alone would have been enough.

It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return
early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch,
no remote version, nothing to compare. It then read `info.updateAvailable`, a
field the route does not return in either mode; the success response carries
status, localVersion, version, remoteVersion, latest_release, cached and
cache_age. `Boolean(undefined)` is false, always. The read type-checked only
because `getVersionInfo()` is declared as `Record<string, unknown>`, so a
property name that does not exist is indistinguishable from one that does.

Let the endpoint do the comparison and read `status === "requires_update"`,
which is what the dashboard stats bar and the profile panel badge already do.

The row's label was `homepage.overviewUpdate`, whose English string is "Up to
date" -- as the label of an update-available row it read "Up to date / Update
available". Nobody has seen that, because the row has never rendered; fixing
the indicator without the label would have shipped it. Give it its own key.
That leaves `homepage.overviewUpdate` unused; it is left in place rather than
removed, since it would be the natural value for an always-visible row and that
is a product decision, not part of this fix.

* fix: capture real client IP for SSH login alerts behind reverse proxy (#1169)

* fix: capture real client IP for SSH login alerts behind reverse proxy

The WebSocket terminal handler used req.socket.remoteAddress for the
"user logged in" alert message, which is the immediate TCP peer (the
reverse proxy) rather than the actual client IP forwarded via
X-Forwarded-For. This made trust-proxy config on Traefik irrelevant
since Termix never read the header for this code path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test: cover getClientIp forwarded-header and socket fallback paths

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* feat: add terminal image handoff

Add authenticated browser upload and clipboard image handoff for terminal agents. Normalize images through Sharp, enforce storage and request limits, preserve host-visible paths, and provide a stable three-button terminal toolbar.

* docs: document terminal image handoff deployment

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com>
Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix(desktop): stop suppressing the update prompt, and make the version badge reachable (#1167)

* fix(desktop): stop suppressing the update prompt for users who need it

The startup update modal stored its dismissal under the local app version
rather than the remote version being offered, and the up-to-date branch
wrote that key with no user interaction at all. A user who launched while
current had their own version recorded; once the next release shipped,
`dismissedVersion === currentVersion` still held and the modal was skipped
on every launch. It reappeared only after the user had already updated --
the inverse of what it is for. Present since v2.3.0.

Key the dismissal on the offered remote version instead. The change is
backward compatible: an existing key holding 2.6.0 compares unequal against
a remote 2.6.1, so affected installs are prompted on their next launch. When
the check itself fails there is no remote version, so nothing is recorded and
no future prompt is suppressed.

That left the version badge as the only remaining signal, and it was an inert
span on both surfaces that render it -- the profile panel and the dashboard
stats bar -- even though the `getVersionInfo()` response it is built from
already carries `latest_release.html_url`. Extract the duplicated badge into
`components/version-badge.tsx` and make the update case a link to the release,
with an accessible name that says where it goes. The beta and stable cases
stay inert.

`getVersionInfo()` returned `Record<string, unknown>`, so the release URL was
unreachable without a cast; give it a `VersionInfo` type that keeps an index
signature, since `SystemOverviewWidget` reads `updateAvailable` off the same
response.

* test: cover the read that actually reaches the badge

The extracted VersionBadge is unit-tested, but the line that decides whether
it ever receives a URL -- pulling `latest_release.html_url` out of the version
response -- was duplicated at both call sites and asserted nowhere. A wrong
property there compiles (the response type keeps an index signature) and every
existing test still passes.

Give it a name, `releaseUrlFrom`, use it from both surfaces, and test it: the
happy path, a response with no release, a release with no URL, and a missing
response, since the caller's fetch can reject. Empty string is the contract the
badge reads as "nothing to link to", so it stays an inert span rather than
rendering a dead anchor.

* docs: state the index signature's real reason

The comment claimed the version endpoint carries fields beyond the typed ones,
citing `updateAvailable`. It does not -- `GET /version` returns status,
localVersion, version, remoteVersion, latest_release, cached and cache_age, and
nothing else. SystemOverviewWidget reads `updateAvailable` off it regardless,
which is why the permissive index signature has to stay, but that is a stale
read rather than an undocumented field. Say so accurately.

* Send alerts in Discord channels with Webhooks (#1158)

* feat(utils): add discord webhook sender

Add a utility to send alert embeds to Discord webhooks.

* fix(utils): validate DNS and use global fetch for outbound requests

Prevent private destination access and rely on global fetch after DNS validation.

* chore(logger): include extra context in logs

Show additional sanitized context entries for clearer diagnostics.

* feat(alerts): support discord channel type in routes and engine

Accept discord channels and route alerts to the Discord sender.

* feat(ui): add Discord option to notification channel dialog

Allow creating/editing Discord webhook channels with username/avatar.

* fix(ui/api): accept structured config payload for notification channels

Allow the client to pass structured config objects (or strings) when creating/updating channels.

* chore: sync Crowdin translations

* fix(homepage): make the System Overview update indicator able to fire (#1168)

The widget's "Update available" row and orange version text were unreachable,
for two independent reasons that each alone would have been enough.

It called `getVersionInfo(false)`, and `checkRemote=false` makes /version return
early with `{localVersion, status: "update_check_disabled"}` -- no GitHub fetch,
no remote version, nothing to compare. It then read `info.updateAvailable`, a
field the route does not return in either mode; the success response carries
status, localVersion, version, remoteVersion, latest_release, cached and
cache_age. `Boolean(undefined)` is false, always. The read type-checked only
because `getVersionInfo()` is declared as `Record<string, unknown>`, so a
property name that does not exist is indistinguishable from one that does.

Let the endpoint do the comparison and read `status === "requires_update"`,
which is what the dashboard stats bar and the profile panel badge already do.

The row's label was `homepage.overviewUpdate`, whose English string is "Up to
date" -- as the label of an update-available row it read "Up to date / Update
available". Nobody has seen that, because the row has never rendered; fixing
the indicator without the label would have shipped it. Give it its own key.
That leaves `homepage.overviewUpdate` unused; it is left in place rather than
removed, since it would be the natural value for an always-visible row and that
is a product decision, not part of this fix.

* fix: capture real client IP for SSH login alerts behind reverse proxy (#1169)

* fix: capture real client IP for SSH login alerts behind reverse proxy

The WebSocket terminal handler used req.socket.remoteAddress for the
"user logged in" alert message, which is the immediate TCP peer (the
reverse proxy) rather than the actual client IP forwarded via
X-Forwarded-For. This made trust-proxy config on Traefik irrelevant
since Termix never read the header for this code path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* test: cover getClientIp forwarded-header and socket fallback paths

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* chore: add url to SENSITIVE_FIELDS for discord url

* fix: enforce SSRF protection on outbound fetches

Use `undici.fetch` with the custom DNS lookup hook to ensure the validated
DNS resolution is the one used for the connection. Fix DNS lookup/address
validation bugs and add coverage for private, public and invalid addresses,
including the resolution issue affecting Discord endpoints.

* chore: prettier format

* fix: validate all DNS addresses and close dispatcher

* fix DNS lookup validation and callback handling
* update safe outbound fetch tests
* ensure created dispatcher is properly closed

* chore: remode url from SENSITIVE_FIELDS for other logs

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>
Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com>
Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>

* fix tmux UTF-8 path handling (#1157)

Co-authored-by: Carl <scarlettme@qq.com>

* chore: update package lock

* chore: update gitnore

* fix: [BUG] (#1049)

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

* fix: test commitlint path fix (#1021)

* fix: SGR mouse-tracking escape codes printed as text (#1023)

* fix: quote $1 in commit-msg hook so it works from git worktrees

* fix: [BUG] could not connect to the database (#1057)

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

* fix: [BUG] VNC connect macOS screen sharing failed (#1063)

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

* fix: [BUG] Meta key (#1075)

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

* fix: [BUG] Remote sync doesn't work with Termix behind nginx proxy (#1085)

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

* fix: [BUG] webhook not working (#1080)

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

* fix: [BUG] First server sync doesn't refresh UI (#1084)

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

* fix: [BUG] How to enable SSL using custom certificate (#1083)

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

* fix: [BUG] Sudo Password Auto-fill Persistance (#1098)

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

* feat: [FEATURE] Expand Snippets Function (#1031)

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

* feat: [FEATURE] (#1055)

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

* feat: [FEATURE] Support for Headscale API Keys (hskey prefix) and Custom API Endpoints (#1013)

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

* feat: [FEATURE] Allow paste on non https (#1026)

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

* feat: be-azerty layout (#1073)

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

* feat: Keyboard shortcuts to move between open tabs (#1069)

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

* feat: Session Logs as a downloadable text file (#1058)

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

* fix: persist and auto-fill saved SSH and sudo passwords

* fix: persist docker runtime selection and docker manager UI issues

* feat: Allow excluding specific mounts from disk usage metrics (#1046)

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

* feat: Expand Snippets Function (#1031)

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

* chore: restore the prettier baseline on dev-2.7.0 (#1185)

Five files on dev-2.7.0 do not match prettier, so `npx prettier --check .`
fails and takes lint-and-build with it — on every pull request, whatever
it changes.

Formatting only, produced by `npx prettier --write` on exactly the files
the check names. No logic touched: tsc passes for both configs, backend
148 files / 1106 tests and UI 71 files / 479 tests all pass.

* test: keep the tmux escaping test runnable on Windows (#1184)

The escaping check ran its command through /bin/sh. That binary does not
exist on Windows, and Windows is a supported platform for the desktop
app, so `npm test` fails there on a test about string quoting. CI is
ubuntu-only and would never see it.

Assert the escaped string directly, which covers the rule on every
platform, and keep the round trip through a real shell as a separate case
guarded by platform -- it is the stronger evidence where a shell exists.

* chore: drop the unreachable table probes from migrateSchema (#1186)

Eleven blocks in migrateSchema() guarded a CREATE TABLE IF NOT EXISTS
behind SELECT id FROM <table> LIMIT 1, for tables the primary bootstrap
had already created earlier in the same startup. The probe could not
throw, so the catch never ran.

Two of those unreachable copies had drifted from the definition actually
in use. sessions had lost ON DELETE CASCADE, and session_recordings still
carried user_id TEXT NOT NULL with ON DELETE CASCADE and no username --
the shape from before audit trails were made to outlive the account. They
would have taken effect had anything ever reordered startup.

Kept, because they are not the same thing:

  - blocks whose catch runs ALTER TABLE ADD COLUMN. CREATE TABLE IF NOT
    EXISTS is a no-op on a table that exists, so a database created before
    a column was added still needs the ALTER. Those probe a column, not a
    table.
  - blocks that are a table's only creation point.
  - the user_open_tabs block, which is a data migration; its SELECT is a
    precondition, not a probe.

Deletion only, no behaviour change.

* fix: repair the frontend type-check and clear the 299 errors behind it (#1189)

* fix: repair frontend type-check configuration and the errors it exposed

The root tsconfig.json is solution-style with "files": [], so the
`npx tsc --noEmit` that CI runs compiles nothing at all. Frontend types
have therefore never been checked, and 299 errors had accumulated behind
that no-op. This clears just over half of them; nothing here changes
runtime behaviour.

Configuration:
- "@/types" resolved through the "@/*" fallback to src/ui/types, which
  does not exist. Added an explicit mapping to src/types/index.ts.
- src/vite-env.d.ts sits outside the include list, so import.meta.env and
  the ?url import suffix were unknown. Added.
- src/ui/types/ held a single file, keybindings.ts, while every other
  shared type lives in src/types/. Six modules imported it as
  "@/types/keybindings" and silently resolved to nothing. Moved.

Type definitions that had fallen behind the code:
- guacamoleConfig and terminalConfig were Record<string, unknown> in
  ui-types while the editor read concrete fields off them. Both now use
  the real interfaces; GuacamoleConfig is extracted from its inline
  definition in guacamole-api.ts so the two cannot drift again.
- customThemeColors and TerminalTheme["colors"] described the same object
  with different optionality. Aligned.
- FileWindow declared its own SSHHost whose authType was "password" | "key",
  which no longer matches the eight the app supports.
- connectSSH and listSSHFiles returned Record<string, unknown>, so every
  field the callers destructured arrived as unknown.
- AxiosRequestConfig and AxiosResponse were used without being imported.

Also adds asHttpError() for the handful of catch blocks that reached into
an unknown binding, and narrows the Host | HostFolder comparator and the
RailItem union at the points where the discriminant was not carrying.

Note: dbHealthMonitor.reportDatabaseError was being called with a second
argument it does not accept, so the authenticated-or-not flag was already
being discarded at runtime. Dropped the argument to match the signature;
whether that flag was meant to gate the report is worth a separate look.

* fix: clear more of the frontend type-check baseline

Continues the previous commit; 140 errors down to 70. Three of these were
real defects rather than missing annotations.

Defects:
- DashboardTab counted active tunnels by comparing status to "CONNECTED",
  but CONNECTION_STATES.CONNECTED is "connected" and that is what the
  tunnel manager emits, so the count was always zero. Now compares against
  the constant.
- QuickActionsCard requires isAdmin and gates a block of admin-only actions
  on it, but neither call site passed it — those actions never rendered.
  Both call sites also passed isAdmin to HostStatusCard, which does not
  accept it; the prop had evidently been moved and the call sites missed.
- The host editor stores jump host ids as strings and sent them straight to
  an API typed for numbers. Backend host lookups compare against an integer
  column, which a string does not match on Postgres or MySQL. Converted.

Types brought in line with the data:
- Host and HostData were missing hasPassword, hasSudoPassword, sortOrder,
  instanceId, connectionOrigin, vaultProfileId, syncId, and the "vault"
  authType; TabContextTab was missing the "tunnel" tab, which TabContext
  already branched on.
- statsConfig and terminalConfig used inline shapes that had drifted from
  StatsConfig and TerminalConfig. Both now reference the real interfaces;
  excludedMounts, which the editor reads, was added to StatsConfig.
- downloadSSHFile, generateKeyPair and generatePublicKeyFromPrivate all
  returned Record<string, unknown> while callers read named fields.
- The Guacamole declarations were missing Keyboard.reset, Client.onfile,
  InputStream.sendAck, Status.Code and BlobReader, all already in use.
- NetworkTopologyNode/Edge could not be discriminated, though the graph
  code tells them apart by testing for source/target.

ProxyNode.type is now 4 | 5 | "http" | "socks4" | "socks5". The editor
writes the string spellings while proxy-helper.ts tests for "http" and
casts everything else to 4|5 before handing it to the socks client, so a
chained proxy reaches it as "socks5" rather than 5. Typed as what is
actually stored; reconciling the two spellings needs a migration decision
and is left alone here.

* fix: continue clearing the frontend type-check baseline

70 errors down to 44.

Dead configuration removed:
- Terminal set terminal.options.bellStyle on xterm, which dropped the
  option in v5. The host editor still exposes the setting and stores it;
  it has simply had no effect on the terminal since that upgrade. Making
  the bell work again means handling the onBell event and is left alone.
- CodeEditor passed scrollPastEnd to basicSetup, which has no such option.
- FileManager passed an id to openWindow, which assigns its own and
  discards what it is given — the component was already being rendered
  under a different id than the one the caller held.

Widgets that were registered but unreachable:
- DockerActivityWidget and SshQuickConnectWidget register under
  "docker_activity" and "ssh_quick_connect", neither of which was in
  WidgetTypeId, and both referenced config interfaces that did not exist.
  Added the ids and the two interfaces, inferred from their edit forms and
  defaultConfig.

More endpoints given their real return types: getRecentFiles,
getPinnedFiles, getFolderShortcuts (arrays, not records), downloadSSHFile,
copySSHItem, generateKeyPair, generatePublicKeyFromPrivate and getSnippets.

parseGuacamoleConfig() handles the host row carrying guacamoleConfig either
parsed or as raw JSON, which GuacamoleApp was reading fields off directly.
TerminalHostConfig was missing name, which it reads for the activity log.

* fix: continue clearing the frontend type-check baseline

44 errors down to 17.

Host and AuditLog are now type aliases rather than interfaces. An
interface has no implicit index signature, so neither could be assigned
to the `[key: string]: unknown` shapes that TerminalHostConfig,
HostMetricsTab's HostConfig and several helpers declare — eight errors
came from that alone.

More dead configuration:
- i18n passed checkWhitelist to the language detector, which no longer
  has that option; supportedLngs already covers it.
- SSHAuthDialog passed scrollPastEnd to basicSetup, same as CodeEditor.
- AudioPreview's onLoadedMetadata never fired: react-h5-audio-player
  spells the prop onLoadedMetaData.
- MarkdownRenderer destructured `inline` from code(), which react-markdown
  removed in v9, so the flag was always undefined and every inline span
  took the block branch when it happened to carry a language class. Now
  derived from whether a className is present at all.
- SnippetsPanel put a title prop on a lucide icon, which does not forward
  it; changed to aria-label so the hint is actually reachable.

updateHostConfig in TabContext replaced tab.hostConfig wholesale with the
six-field literal it receives, dropping everything else the tab held about
the host. It now merges onto the existing config.

Also: getReleasesRSS, getUserAlerts and getVersionInfo have real return
types (UpdateLog kept private copies of two of them, and VersionInfo was
missing `version`, which the endpoint sends and the panel renders);
wakeOnLan and vncCredentialId get the numeric ids they are typed for; and
the tmux formatter takes i18next's TFunction instead of a hand-written
signature it does not satisfy.

* fix: clear the last frontend type errors and make CI actually run the check

Baseline is now zero, so the check can be turned on.

`npx tsc --noEmit` — what CI ran and what `npm run type-check` was — compiles
nothing: the root tsconfig.json is solution-style with "files": [], and
plain tsc does not follow project references. Both are now `tsc -b`, which
builds tsconfig.app.json and tsconfig.node.json. Verified by planting a type
error and watching the command fail.

Last defects in this batch:
- patchOpenTab could not carry hostId, so quick-connect's "save this host
  and attach the tab to it" call was passing a field excluded from the
  type all the way down. The column exists and updateForUser spreads
  whatever it receives, so the write worked; only the types disagreed.
  Widened front to back.
- The file-comparison window opened without x, y, width or height — every
  other openWindow call passes them — and sent a `type` field WindowInstance
  does not have.
- HostEditor gated a block on authType === "warpgate", which is not one of
  the eight authType values. Unreachable, and it held only a label and a
  description. Removed.
- FileManager passed onLoadDirectory to a sidebar that neither declares nor
  reads it, and FileManagerApp passed embedded to a FileManager that has no
  such prop.
- TunnelApp's minimal Host was missing three required flags.

The remainder were assertions at boundaries that are genuinely loose: bulk
host import takes rows assembled from untyped input and validates them
server-side, and a vi.fn() whose body only throws infers never.

* feat: add drive file browser and drag-and-drop upload for RDP (#1187)

Drive redirection could already be enabled per host, but the redirected
drive lived inside guacd with no way to reach it from the browser: the
client never handled onfilesystem, so the mounted volume was writable
from Windows and invisible from Termix.

Add a file browser panel that lists the drive, downloads files, and
uploads them, plus drag-and-drop onto the display which opens the panel
and uploads into the directory currently shown. The disable-upload and
disable-download connection settings are honoured by the UI, not just
passed to guacd.

A rejected upload stops the BlobWriter without firing onerror or
oncomplete, so the error ack is watched explicitly; otherwise the
transfer would hang forever. Directory reads carry a deadline for the
same reason.

Also declares Guacamole.Object, Client.onfilesystem, BlobReader and
BlobWriter in the local type definitions, which previously omitted them.

* fix: keep the mouse working on touch-capable devices in RDP/VNC (#1190)

Reported as "mouse input broken, keyboard fine" after 2.5.1 (#1102).

2.5.1 bound Guacamole.Mouse unconditionally. 2.6.0 replaced that with a
three-way branch on touchMode, and the touch branches replace the mouse
binding instead of adding to it:

    if (touchMode === "touchscreen")      new Guacamole.Mouse.Touchscreen(el)
    else if (touchMode === "touchpad")    new Guacamole.Mouse.Touchpad(el)
    else                                  new Guacamole.Mouse(el)

The two do not overlap. Guacamole.Mouse listens for mousedown/mousemove/
mouseup; Touchscreen and Touchpad listen only for touchstart/touchmove/
touchend. So in a touch mode nothing is listening for the mouse at all.

touchMode defaults to "touchscreen" whenever navigator.maxTouchPoints > 0,
which is true of every laptop with a touchscreen — machines that are still
driven by a mouse. Those users lost the pointer entirely while the keyboard
kept working, because Guacamole.Keyboard is bound independently.

The physical pointer is now always bound and a touch emulator is layered on
top when one is selected. Extracted to bindPointerInput() so the binding is
testable; the test fails against the old branch.

Note the issue also carries a second, unrelated report where well-formed
mouse frames do reach guacd and the VNC leg ignores them. That one is not
this, and the guacd image is pinned to 1.6.0 in both 2.5.1 and 2.6.1, so it
is not an upgrade either.

* fix: deduplicate /api/folders requests to prevent intermittent folder disappearance (#1191)

* chore: sync Crowdin translations

* fix: deduplicate /api/folders requests to prevent intermittent folder disappearance

getSSHFolders() had no request deduplication while getSSHHosts() used a TTL
cache with in-flight dedupe. When loadHosts() fired multiple times during
rapid navigation between Credentials and Hosts panels, the folder response
could arrive after the hosts response, causing the sidebar tree to render
without folder metadata.

- Add foldersCache (10s TTL) in hosts-request-cache.ts
- Wrap getSSHFolders() API call in getCachedSSHFolders()
- Invalidate folders cache on renameFolder, updateFolderMetadata,
  deleteAllHostsInFolder, and renameCredentialFolder
- Include foldersCache in invalidateHostsAndStatusCaches()

Closes Termix-SSH/Support#1103

Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com>

---------

Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com>
Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* chore(deps): bump undici from 8.9.0 to 8.10.0 in the prod-minor-updates group (#1195)

* chore: sync Crowdin translations

* chore(deps): bump undici in the prod-minor-updates group

Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici).


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

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>

---------

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

* feat: proxmox metrics integration

* feat: add folder select to the host multi select feature

* feat: implement context aware terminal toolbar with quick links, host info, image pasting, etc

* feat: made toolbar open file manager at path

* fix: delete folder route not invalidating host list cache

* fix: match host list icons with tab bar iconfix

* fix: change sidebar reset button icon to seperate against fullscreen button

* feat: unify connection system and add connection logs to guacd hosts

* fix: make mobile terminal scrollback match xterm wheel behavior (#1198)

* fix: route mobile terminal scrolling through xterm viewport

* docs: document mobile terminal touch scrolling

* chore: add a note to not place files in docs

* chore: remove touch imput from docs

* feat: improve snippet system with variable snippets and collapse settings

* feat: new fleet system with snippet, packages, files, and inventory features

* fix: command pallete not loading new activity and made enter load first item

* feat: add subhost from parent host organization feature

* feat: add workspaces feature to save tab layout

* perf: greatly improved performance across metrics polling and host management for enterprise users

* feat: add a onboarding system with a new interface simplicity system

* feat: finalize the multi dialect database system

* fix: bind trusted MFA devices to client installs (#1202)

* fix: merge OIDC group claims across sources (#1203)

* fix: allow disabling SSH keepalives (#1204)

* fix: distinguish reachable and available hosts (#1206)

* fix: throttle session activity persistence (#1207)

* fix: preserve saved RDP connection settings (#1208)

* fix: authenticate tunnel status stream (#1209)

* fix: select quick-created credentials (#1210)

* fix: stagger initial metrics collection (#1211)

* fix: stagger initial metrics collection

* fix: admit reachable hosts to initial metrics

* fix: prevent long host names shifting dashboard metrics (#1205)

* feat: add global touch input settings (#1201)

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>

* fix: keep host list row sizing stable (#1213)

* fix(guacamole): correct Windows key mapping (#1216)

* fix: normalize OIDC discovery issuer URLs (#1218)

* fix: prompt for RDP domain credentials (#1212)

* fix: route status checks by connection origin (#1214)

* fix: restore desktop Tailscale configuration (#1215)

* fix(docker): restore Node 24 for ssh2 native crypto (#1217)

* feat: added new automations feature with events, channels, and steps

* feat: allowed some tabs in the app rail to be opened as its own tab or in a new right sidebar

* feat: expand onboarding process with more customization/features

* feat: initial implementation of the termix ai feature

* chore: run linter

* fix: issue #424 (#424)

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

* fix: Not working without internet connection. Missing OPKSSH binary in pre-built image. (#1133)

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

* fix: SQLite forceSave on telemetry writes causes periodic SSH terminal stalls in 2.6.x (#1109)

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

* feat: How to enable SSL using custom certificate (#1083)

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

* fix: show profile API key after creation (#1221)

* feat: add trusted proxy authentication (#1222)

* fix: clarify SSH agent authentication (#1224)

* feat: add first-class split screen tabs (#1226)

* feat: add split tab data model

* feat: make split screens top-level tabs

* feat: persist and manage split layouts

* feat: launch native RDP on Windows desktop (#1223)

* feat: launch native RDP on Windows

* style: format native RDP launcher

* feat: enhance custom disk and network metrics (#1220)

* feat: enhance host disk and network metrics

* fix: align enhanced metrics types

* fix: preserve Proxmox guest identity on edit (#1219)

* fix: preserve Proxmox guest identity on edit

* fix: type Proxmox guest source metadata

* chore: dead-code cleanup and small refactors (#1225)

* chore: remove dead code and unused exports

* chore: remove unused api client functions

* chore: remove unused backend helpers

* refactor: extract getErrorMessage helper for repeated error extraction

* refactor: unify error message extraction across backend with getErrorMessage

* refactor: unify error message extraction in frontend with getErrorMessage

* refactor: merge duplicate imports from the same module

* refactor: use Array.includes in TabBar

* chore: drop biome, keep prettier as the single formatter

* style: apply prettier formatting to refactored files

* fix: close active tab with Ctrl+W on Windows

* fix: make tray Quit terminate the desktop app

* feat: verify host transfer integrity

* fix: reuse transfer sessions during verification

* feat: select the fastest host transfer route

* feat: tune host transfers adaptively

* feat: adapt background polling to activity (#1233)

* feat: adapt background polling to activity

* feat: extend adaptive polling coverage

* feat: make polling cost and network aware (#1234)

* feat: make repeat navigation feel instant (#1235)

* feat: make file operations feel immediate (#1236)

* feat: preload likely user actions (#1237)

* feat: preload likely file previews

* feat: preload likely host tools

* feat: preload likely file viewers

* fix: replace stale terminal input listeners

* feat: add links to docs for all new features

* chore: update readme

* fix: warn before discarding host changes (#1229)

* feat: learn local host action preferences (#1238)

* feat(terminal-toolbar): add bounded movable desktop toolbar (#1239)

* feat: add local adaptive decision engine (#1240)

* feat: adapt speculative resource usage (#1241)

* feat: persist adaptive transfer profiles (#1242)

* Fix .preferred_username when using LDAP login. (#1243)

* chore: sync Crowdin translations

* Fix .preferred_username when using LDAP login. Strips internal LDAP prefix from username.

---------

Co-authored-by: LukeGus <bugattiguy527@gmail.com>

* feat: learn direct transfer routes (#1244)

* feat: learn speculative preload usefulness (#1245)

* fix: - Adjusting the SSH Authentication from Vault to something else fails (#1152)

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

* fix: terminal graphical display, special characters inserted, distorted - `midnight comma... (#1145)

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

* feat: single click on host in list opens session - should be only on double click (#1146)

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

* feat: Terminal: custom font/ font selection/ how-to for adding a font - `MesloLGS NF` (#1140)

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

* fix: revert host single click to open session, make double click an option (#1146)

Single click opens a session again by default. The old double click
behavior can be turned on in Customize Sidebar.

* chore: drop prettier check from beta release workflow, run formatter

* chore: patch dependabot vulnerabilities via npm overrides

* fix: reset adaptive resource state between tests to stop cross-test leaks

* feat: replace terminal toolbar density popover with a native select

* fix: pin hardwareConcurrency in adaptive budget tests so CI cores don't change the tier

* fix: allow dylib files in mac universal arch rules so mas build packages sharp

* feat: add file manager trash (#1250)

* feat: add inheritable connection defaults (#1246)

* feat: add desktop local terminal (#1247)

* feat: add interactive terminal macros (#1248)

* feat: add adaptive SSH local echo (#1249)

* fix: sync desktop host changes immediately (#1252)

* fix: route desktop sharing through synced server (#1253)

* Fix terminal image uploads and add safe diagnostics (#1254)

* feat: add configurable terminal image storage backends

* feat: add admin image storage settings

* fix: preserve native clipboard PNG uploads

* fix: quote terminal image paths safely

* docs: record image storage security remediation plan

* fix: close remote image SFTP channels

* fix: restrict remote image SFTP permissions

* fix: bound remote image SFTP writes

* fix: add best effort remote image retention

* fix: cap normalized image output size

* fix: bound concurrent image processing

* fix: fail closed on local image inspection errors

* test: cover fail closed image storage and atomic settings

* fix: enforce remote image quota and upload admission

* fix: serialize remote quota and verify existing paths

* fix: use synchronous sqlite settings transaction

* fix: keep settings transactions portable across dialects

* fix: bound image processing admission queue

* fix: serialize remote image quota across processes

* fix: recover stale remote image locks safely

* fix: preserve remote storage errors during unlock

* fix: fail closed when stale lock removal fails

* fix: harden image upload resource and storage cleanup

* fix: bound SFTP operations and lock lifetime

* fix: bound SFTP acquisition and cleanup callbacks

* fix: close late SFTP channels and test cleanup stalls

* fix: preserve SFTP inspection client context

* feat: add image upload source metadata

* fix: expose image upload metadata in logs

* chore: exclude internal plan from pull request

* style: apply prettier formatting

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>

* fix: batch of security hardening fixes (#1255)

* fix: bind desktop auto-session loopback check to the TCP peer address

* fix: escape HTML entities in Vault OIDC callback responses

* fix: route homepage ping and rss through the SSRF-safe outbound fetch

* fix: scope tunnel status endpoints to hosts the caller can access

* chore: update release notes

* chore: update release notes to write more about the ai integration

* fix: unbreak windows and macos electron builds after node-pty

Install Spectre-mitigated MSVC libs on the Windows runner and cover
node-pty's spawn-helper in the macOS universal arch rules.

* fix: rework connection defaults ui into a dialog and add missing i18n keys

* fix: rework macros panel with i18n, plain text matching, and list layout

* feat: add docs links for trash, connection defaults, and local echo

* fix: make image storage and trash tests pass on windows

* fix: stop docs links squeezing sidebar panel headers

* fix: put automations docs link back on the tabs row

* fix(desktop): keep Linux credential storage working on unrecognised desktops (#1261)

Chromium resolves safeStorage's backend from XDG_CURRENT_DESKTOP and falls
back to the basic_text store for any desktop it has no mapping for, which
covers every wlroots-style compositor (Hyprland, sway, niri, river).
isEncryptionAvailable() reports false for that store, so saveRemoteSyncJwt
refused every write and the OIDC sign-in it was storing appeared to succeed.
The sync engine then found no JWT and reported the session as expired, which
sent users looking at their OIDC provider for a fault that was never there.

Name the libsecret backend explicitly on those desktops. They run an ordinary
Secret Service, so that is enough to make encryption available again. KWallet
desktops keep their auto-detected backend, an explicit --password-store still
wins, and no stored secret can be orphaned by the switch because
isEncryptionAvailable() gated every write that would have created one.

Also stop discarding the {success: false} the main process returns when it
cannot store a credential: on a machine with no Secret Service at all, the
sign-in now says so instead of silently completing.

Co-authored-by: alexandre-vl <rafaelsenchais@gmail.com>

* chore: update release notes

* chore: update release notes

* fix(file-manager): widen trash dialog so names and paths are not cut off

* fix(sidebar): stop hover action tray overlapping the row below it

* fix(hosts): make real status colors toggle actually apply

* feat(local-terminal): add rail button and fix hardcoded tab label

* chore: update release notes

* fix(ai): hide assistant everywhere when admin disables it globally

* fix(automations): fix concurrency race, wire docker and internal event triggers

Claim the in-flight slot in the same tick it is checked, poll container
state for docker_event triggers, emit the internal events, apply the
schedule time zone, and expose the concurrency policy in the editor.

* fix(sidebar): rework host and credential drag-to-reorder

Adds a lock toggle in the sort menu and fixes reorder positioning,
cross-folder drops, and the duplicate drop indicator.

* chore(sidebar): drop unused sortKey prop from host and credential trees

* fix(sidebar): fix row height in click tray mode so status stripes stop overlapping

* fix(onboarding): remove add-first-host step that closed onboarding mid-flow

* fix(release): upload release notes so Mac App Store review submission stops failing

* chore: sync Crowdin translations for 2.7.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: RawNuke <67506722+RawNuke@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com>
Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: T3rM1nAt0-R <niraj.sangani91@gmail.com>
Co-authored-by: Horziox <horziox.dev@gmail.com>
Co-authored-by: William Shi <184219650@qq.com>
Co-authored-by: Carl <scarlettme@qq.com>
Co-authored-by: Raw_Nuke <67506722+RawNuke@users.noreply.github.com>
Co-authored-by: njz-cvm <njz@cvm.com>
Co-authored-by: Alexandre VARGAS <alexandre.vargas.lopez@gmail.com>
Co-authored-by: alexandre-vl <rafaelsenchais@gmail.com>
This commit is contained in:
837 files changed
+387941 -15831

No files matched your search

+11 -2
View File
@@ -1,8 +1,7 @@
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { deleteApiKey } from "@/main-axios";
import type { ApiKey } from "@/main-axios";
import { deleteApiKey, type ApiKey } from "@/main-axios";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Copy, Network, Plus, RefreshCw, Trash2 } from "lucide-react";
@@ -72,6 +71,16 @@ export function AdminApiKeysSection({
>
{t("admin.apiKeysDocsLink")}
</a>
<br />
{t("admin.apiKeysCliHint")}{" "}
<a
href="https://docs.termix.site/cli"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.apiKeysCliLink")}
</a>
</span>
<div className="flex items-center gap-1">
<Button
+240
View File
@@ -0,0 +1,240 @@
import { useTranslation } from "react-i18next";
import { ImageIcon } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { SettingRow } from "@/components/section-card";
import type {
TerminalImageStorageMode,
TerminalImageStorageSettings,
TerminalImageStorageTestResult,
} from "@/api/settings-api";
import { AccordionSection } from "./AdminSettingsShared";
const MODE_LABEL_KEYS: Record<TerminalImageStorageMode, string> = {
auto: "admin.imageStorageModeAuto",
local: "admin.imageStorageModeLocal",
"remote-sftp": "admin.imageStorageModeRemoteSftp",
};
const SELECTED_MODE_LABEL_KEYS: Record<
TerminalImageStorageTestResult["selectedMode"],
string
> = {
local: "admin.imageStorageModeLocal",
"remote-sftp": "admin.imageStorageModeRemoteSftp",
unavailable: "admin.imageStorageSelectedModeUnavailable",
};
export function AdminImageStorageSection({
open,
onToggle,
settings,
setSettings,
localDir,
setLocalDir,
instanceId,
setInstanceId,
saving,
testing,
testResult,
onSave,
onTest,
}: {
open: boolean;
onToggle: () => void;
settings: TerminalImageStorageSettings | null;
setSettings: (settings: TerminalImageStorageSettings) => void;
localDir: string;
setLocalDir: (value: string) => void;
instanceId: string;
setInstanceId: (value: string) => void;
saving: boolean;
testing: boolean;
testResult: TerminalImageStorageTestResult | null;
onSave: () => void;
onTest: () => void;
}) {
const { t } = useTranslation();
return (
<AccordionSection
label={t("admin.sectionImageStorage")}
icon={<ImageIcon className="size-3.5" />}
open={open}
onToggle={onToggle}
>
{settings === null ? (
<div className="pt-3 text-xs text-muted-foreground">
{t("common.loading")}
</div>
) : (
<div className="flex flex-col gap-0 pt-2">
<SettingRow
label={t("admin.imageStorageMode")}
description={t("admin.imageStorageModeDesc")}
>
<div className="flex gap-1">
{(Object.keys(MODE_LABEL_KEYS) as TerminalImageStorageMode[]).map(
(mode) => (
<button
key={mode}
type="button"
aria-pressed={settings.mode === mode}
onClick={() => setSettings({ ...settings, mode })}
className={`px-2 py-1 text-[10px] font-semibold border transition-colors ${settings.mode === mode ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{t(MODE_LABEL_KEYS[mode])}
</button>
),
)}
</div>
</SettingRow>
<div className="grid grid-cols-1 gap-3 py-3 sm:grid-cols-2 border-b border-border">
<label className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t("admin.imageStorageLocalDir")}
</span>
<Input
aria-label={t("admin.imageStorageLocalDir")}
value={localDir}
placeholder={t("admin.imageStorageLocalDirPlaceholder")}
onChange={(event) => setLocalDir(event.target.value)}
className="h-8 text-xs"
/>
<span className="text-muted-foreground">
{t("admin.imageStorageLocalDirDesc")}
</span>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t("admin.imageStorageHostPath")}
</span>
<Input
aria-label={t("admin.imageStorageHostPath")}
value={settings.hostPath}
onChange={(event) =>
setSettings({ ...settings, hostPath: event.target.value })
}
className="h-8 text-xs"
/>
<span className="text-muted-foreground">
{t("admin.imageStorageHostPathDesc")}
</span>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t("admin.imageStorageTtl")}
</span>
<Input
aria-label={t("admin.imageStorageTtl")}
type="number"
min={0}
value={settings.ttlMs}
onChange={(event) =>
setSettings({
...settings,
ttlMs: Number(event.target.value),
})
}
className="h-8 text-xs"
/>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t("admin.imageStorageMaxCount")}
</span>
<Input
aria-label={t("admin.imageStorageMaxCount")}
type="number"
min={1}
value={settings.maxCount}
onChange={(event) =>
setSettings({
...settings,
maxCount: Number(event.target.value),
})
}
className="h-8 text-xs"
/>
</label>
<label className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t("admin.imageStorageMaxBytes")}
</span>
<Input
aria-label={t("admin.imageStorageMaxBytes")}
type="number"
min={1048576}
value={settings.maxBytes}
onChange={(event) =>
setSettings({
...settings,
maxBytes: Number(event.target.value),
})
}
className="h-8 text-xs"
/>
</label>
</div>
<SettingRow
label={t("admin.imageStorageInstanceId")}
description={t("admin.imageStorageInstanceIdDesc")}
>
<Input
aria-label={t("admin.imageStorageInstanceId")}
value={instanceId}
onChange={(event) => setInstanceId(event.target.value)}
className="h-8 w-48 text-xs"
/>
</SettingRow>
{testResult && (
<div className="py-2 text-xs text-muted-foreground border-b border-border">
<div>
{testResult.connected
? t("admin.imageStorageTestConnected")
: t("admin.imageStorageTestNotConnected")}
</div>
<div>
{t("admin.imageStorageTestSelectedMode", {
mode: t(SELECTED_MODE_LABEL_KEYS[testResult.selectedMode]),
})}
</div>
{testResult.localHostVisible !== null && (
<div>
{testResult.localHostVisible
? t("admin.imageStorageTestLocalVisible")
: t("admin.imageStorageTestLocalNotVisible")}
</div>
)}
</div>
)}
<div className="flex gap-2 pt-3">
<Button
size="sm"
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={saving}
onClick={onSave}
>
{saving ? t("common.saving") : t("common.save")}
</Button>
<Button
size="sm"
variant="outline"
disabled={testing}
onClick={onTest}
>
{testing
? t("admin.imageStorageTesting")
: t("admin.imageStorageTest")}
</Button>
</div>
</div>
)}
</AccordionSection>
);
}
+54 -1
View File
@@ -80,6 +80,12 @@ type UsersSectionProps = {
>;
setUnlinkAccountOpen: Dispatch<SetStateAction<boolean>>;
onManageUser: (user: AdminUser) => void;
search: string;
onSearchChange: (value: string) => void;
page: number;
pageSize: number;
total: number;
onPageChange: Dispatch<SetStateAction<number>>;
};
export function AdminUsersSection({
@@ -96,8 +102,17 @@ export function AdminUsersSection({
setUnlinkAccountTarget,
setUnlinkAccountOpen,
onManageUser,
search,
onSearchChange,
page,
pageSize,
total,
onPageChange,
}: UsersSectionProps) {
const { t } = useTranslation();
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const hasPrev = page > 0;
const hasNext = page + 1 < pageCount;
return (
<AccordionSection
@@ -109,7 +124,7 @@ export function AdminUsersSection({
<div className="flex flex-col pt-2">
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.usersCount", { count: users.length })}
{t("admin.usersCount", { count: total })}
</span>
<div className="flex items-center gap-1">
<Button
@@ -131,6 +146,14 @@ export function AdminUsersSection({
</Button>
</div>
</div>
<div className="py-2 border-b border-border">
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={t("admin.searchUsers")}
className="h-7 text-xs rounded-none"
/>
</div>
{users.map((user) => {
const authLabel =
user.isOidc && user.passwordHash
@@ -245,6 +268,36 @@ export function AdminUsersSection({
</div>
);
})}
{users.length === 0 && (
<div className="py-4 text-center text-[10px] text-muted-foreground">
{search ? t("admin.noUsersMatch") : t("admin.noUsers")}
</div>
)}
{pageCount > 1 && (
<div className="flex items-center justify-between py-2 border-t border-border">
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] rounded-none"
disabled={!hasPrev}
onClick={() => onPageChange((p) => Math.max(0, p - 1))}
>
{t("common.previous")}
</Button>
<span className="text-[10px] text-muted-foreground">
{t("admin.pageOf", { page: page + 1, total: pageCount })}
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] rounded-none"
disabled={!hasNext}
onClick={() => onPageChange((p) => p + 1)}
>
{t("common.next")}
</Button>
</div>
)}
</div>
</AccordionSection>
);
+223 -10
View File
@@ -1,5 +1,12 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { notifyAiStatusChanged } from "@/hooks/use-ai-availability";
import {
getAiGloballyEnabled,
getAiPrivateEndpoints,
setAiGloballyEnabled as setAiGloballyEnabledApi,
setAiPrivateEndpoints as setAiPrivateEndpointsApi,
} from "@/api/ai-api";
import {
getUserList,
getSessions,
@@ -42,7 +49,12 @@ import {
updateHostDefaults,
getAnalyticsEnabled,
updateAnalyticsEnabled,
getTerminalImageStorageSettings,
updateTerminalImageStorageSettings,
testTerminalImageStorage,
type HostDefaults,
type TerminalImageStorageSettings,
type TerminalImageStorageTestResult,
} from "@/api/settings-api";
import {
getSessionSharingGloballyEnabled,
@@ -65,9 +77,13 @@ import {
saveMetricsHistoryRetention,
} from "@/api/host-metrics-api";
import type { SSOProvider } from "@/types/index";
import type { ApiKey, CreatedApiKey, UserRole } from "@/main-axios";
import type { AdminSection } from "@/types/ui-types";
import type { Role } from "@/main-axios";
import {
type ApiKey,
type CreatedApiKey,
type Role,
type UserRole,
} from "@/main-axios";
import { type AdminSection, type Host } from "@/types/ui-types";
import {
AdminRolesSection,
AdminSessionsSection,
@@ -94,7 +110,17 @@ import {
AdminUnlinkAccountDialog,
} from "./AdminUserDialogs";
import { AdminUserManagePanel } from "./AdminUserManagePanel";
import type { Host } from "@/types/ui-types";
import {
TOUCH_INPUT_DEFAULTS,
type TouchInputSettings,
} from "@/types/touch-input-settings";
import {
getTouchInputSettings,
updateTouchInputSettings,
} from "@/api/touch-input-settings-api";
import { cacheTouchInputSettings } from "@/features/terminal/touch-input-settings-store";
import { AdminTouchInputSection } from "./AdminTouchInputSection";
import { AdminImageStorageSection } from "./AdminImageStorageSection";
type ApiErrorLike = {
response?: {
@@ -108,6 +134,8 @@ function apiErrorMessage(error: unknown, fallback: string) {
return (error as ApiErrorLike).response?.data?.error || fallback;
}
const USERS_PAGE_SIZE = 25;
export function AdminSettingsPanel({
onEditingChange,
onOpenHostTab,
@@ -131,12 +159,28 @@ export function AdminSettingsPanel({
const [guacUrl, setGuacUrl] = useState("guacd:4822");
const [logLevel, setLogLevel] = useState("info");
const [tailscaleApiKey, setTailscaleApiKey] = useState("");
const [tailscaleApiBaseUrl, setTailscaleApiBaseUrl] = useState("");
const [commandHistoryEnabled, setCommandHistoryEnabled] = useState(true);
const [analyticsEnabled, setAnalyticsEnabled] = useState(true);
const [analyticsLocked, setAnalyticsLocked] = useState(false);
const [sessionSharingGloballyEnabled, setSessionSharingGloballyEnabled] =
useState(true);
const [aiGloballyEnabled, setAiGloballyEnabled] = useState(false);
const [aiPrivateEndpoints, setAiPrivateEndpoints] = useState<string[]>([]);
const [hostDefaults, setHostDefaults] = useState<HostDefaults>({});
const [touchInputSettings, setTouchInputSettings] =
useState<TouchInputSettings>({ ...TOUCH_INPUT_DEFAULTS });
// Terminal image storage state. localDir stays a draft: the API never
// returns the configured backend path, so it is only sent when changed.
const [imageStorageSettings, setImageStorageSettings] =
useState<TerminalImageStorageSettings | null>(null);
const [imageStorageLocalDir, setImageStorageLocalDir] = useState("");
const [imageStorageInstanceId, setImageStorageInstanceId] = useState("");
const [imageStorageSaving, setImageStorageSaving] = useState(false);
const [imageStorageTesting, setImageStorageTesting] = useState(false);
const [imageStorageTestResult, setImageStorageTestResult] =
useState<TerminalImageStorageTestResult | null>(null);
// SSO / auto-provision state
const [oidcAutoProvision, setOidcAutoProvision] = useState(false);
@@ -215,12 +259,14 @@ export function AdminSettingsPanel({
const [manualUploading, setManualUploading] = useState(false);
const [users, setUsers] = useState<AdminUser[]>([]);
const [userSearch, setUserSearch] = useState("");
const [userPage, setUserPage] = useState(0);
const [userTotal, setUserTotal] = useState(0);
const [sessions, setSessions] = useState<AdminSession[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
useEffect(() => {
loadUsers();
loadSessions();
loadRoles();
loadApiKeys();
@@ -228,6 +274,17 @@ export function AdminSettingsPanel({
loadSSOProviders();
}, []);
// Debounced so typing in the search box does not fire a request per keystroke.
useEffect(() => {
const timer = setTimeout(loadUsers, userSearch ? 250 : 0);
return () => clearTimeout(timer);
}, [userSearch, userPage]);
// A new search term starts again from the first page.
useEffect(() => {
setUserPage(0);
}, [userSearch]);
useEffect(() => {
onEditingChange?.(manageUser !== null);
return () => onEditingChange?.(false);
@@ -245,8 +302,14 @@ export function AdminSettingsPanel({
}, [editUserOpen, editUserTarget]);
function loadUsers() {
getUserList()
.then(({ users: u }) =>
// Paged server-side so an install with thousands of accounts does not
// ship the whole directory to render one screen of it.
getUserList({
search: userSearch.trim() || undefined,
limit: USERS_PAGE_SIZE,
offset: userPage * USERS_PAGE_SIZE,
})
.then(({ users: u, total }) => {
setUsers(
u.map((user) => ({
id: user.userId,
@@ -257,8 +320,9 @@ export function AdminSettingsPanel({
dataUnlocked: user.data_unlocked,
totpEnabled: user.totp_enabled,
})),
),
)
);
setUserTotal(total ?? u.length);
})
.catch(() => {});
}
@@ -296,6 +360,10 @@ export function AdminSettingsPanel({
cmdHistory,
analytics,
sessionSharingEnabled,
touchInput,
aiEnabled,
aiEndpoints,
imageStorage,
] = await Promise.allSettled([
getRegistrationAllowed(),
getPasswordLoginAllowed(),
@@ -310,6 +378,10 @@ export function AdminSettingsPanel({
getCommandHistoryEnabled(),
getAnalyticsEnabled(),
getSessionSharingGloballyEnabled(),
getTouchInputSettings(),
getAiGloballyEnabled(),
getAiPrivateEndpoints(),
getTerminalImageStorageSettings(),
]);
if (reg.status === "fulfilled") setAllowRegistration(reg.value.allowed);
@@ -337,6 +409,7 @@ export function AdminSettingsPanel({
}
if (tailscale.status === "fulfilled") {
setTailscaleApiKey(tailscale.value.apiKey ?? "");
setTailscaleApiBaseUrl(tailscale.value.apiBaseUrl ?? "");
}
if (cmdHistory.status === "fulfilled") {
setCommandHistoryEnabled(cmdHistory.value.enabled);
@@ -348,6 +421,19 @@ export function AdminSettingsPanel({
if (sessionSharingEnabled.status === "fulfilled") {
setSessionSharingGloballyEnabled(sessionSharingEnabled.value.enabled);
}
if (touchInput.status === "fulfilled") {
setTouchInputSettings(touchInput.value);
cacheTouchInputSettings(touchInput.value);
}
if (aiEnabled.status === "fulfilled") {
setAiGloballyEnabled(aiEnabled.value);
}
if (aiEndpoints.status === "fulfilled") {
setAiPrivateEndpoints(aiEndpoints.value);
}
if (imageStorage.status === "fulfilled") {
setImageStorageSettings(imageStorage.value);
}
} catch {
// non-fatal
}
@@ -478,6 +564,90 @@ export function AdminSettingsPanel({
}
}
async function handleToggleAiGloballyEnabled() {
const newVal = !aiGloballyEnabled;
setAiGloballyEnabled(newVal);
try {
await setAiGloballyEnabledApi(newVal);
// Every AI surface listens for this, so the admin sees the entry appear
// or disappear right away instead of after a reload.
notifyAiStatusChanged();
} catch {
setAiGloballyEnabled(!newVal);
toast.error(t("admin.updateAiEnabledFailed"));
}
}
async function handleSaveAiPrivateEndpoints(hosts: string[]) {
const previous = aiPrivateEndpoints;
setAiPrivateEndpoints(hosts);
try {
setAiPrivateEndpoints(await setAiPrivateEndpointsApi(hosts));
} catch {
setAiPrivateEndpoints(previous);
toast.error(t("admin.updateAiEndpointsFailed"));
}
}
async function saveTouchInputSettings(settings = touchInputSettings) {
try {
const saved = await updateTouchInputSettings(settings);
setTouchInputSettings(saved);
cacheTouchInputSettings(saved);
toast.success(t("admin.touchSaved"));
} catch {
toast.error(t("admin.touchSaveFailed"));
}
}
function resetTouchInputSettings() {
const defaults = { ...TOUCH_INPUT_DEFAULTS };
setTouchInputSettings(defaults);
void saveTouchInputSettings(defaults);
}
async function handleSaveImageStorage() {
if (!imageStorageSettings) return;
setImageStorageSaving(true);
try {
const saved = await updateTerminalImageStorageSettings({
mode: imageStorageSettings.mode,
hostPath: imageStorageSettings.hostPath,
ttlMs: imageStorageSettings.ttlMs,
maxCount: imageStorageSettings.maxCount,
maxBytes: imageStorageSettings.maxBytes,
...(imageStorageLocalDir.trim()
? { localDir: imageStorageLocalDir.trim() }
: {}),
});
setImageStorageSettings(saved);
setImageStorageLocalDir("");
toast.success(t("admin.imageStorageSaved"));
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.imageStorageSaveFailed")));
} finally {
setImageStorageSaving(false);
}
}
async function handleTestImageStorage() {
if (!imageStorageInstanceId.trim()) {
toast.error(t("admin.imageStorageInstanceIdRequired"));
return;
}
setImageStorageTesting(true);
setImageStorageTestResult(null);
try {
setImageStorageTestResult(
await testTerminalImageStorage(imageStorageInstanceId.trim()),
);
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.imageStorageTestFailed")));
} finally {
setImageStorageTesting(false);
}
}
async function handleSaveSessionTimeout() {
const hours = parseInt(sessionTimeout, 10);
if (isNaN(hours) || hours < 1 || hours > 720) {
@@ -542,7 +712,7 @@ export function AdminSettingsPanel({
async function handleSaveTailscaleApiKey() {
try {
await updateTailscaleSettings(tailscaleApiKey);
await updateTailscaleSettings(tailscaleApiKey, tailscaleApiBaseUrl);
toast.success(t("admin.tailscaleSettingsSaved"));
} catch {
toast.error(t("admin.tailscaleSettingsSaveFailed"));
@@ -632,6 +802,9 @@ export function AdminSettingsPanel({
const result = await requestAcmeCertificate();
setAcmeSettings(result);
toast.success(t("admin.sslRequestCertSuccess"));
if (result.reloadMessage) {
toast.info(result.reloadMessage);
}
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.sslRequestCertFailed")));
} finally {
@@ -654,6 +827,9 @@ export function AdminSettingsPanel({
setManualCertDraft("");
setManualKeyDraft("");
toast.success(t("admin.sslManualUploadSuccess"));
if (result.reloadMessage) {
toast.info(result.reloadMessage);
}
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.sslManualUploadFailed")));
} finally {
@@ -934,6 +1110,10 @@ export function AdminSettingsPanel({
analyticsLocked={analyticsLocked}
handleToggleAnalytics={handleToggleAnalytics}
sessionSharingGloballyEnabled={sessionSharingGloballyEnabled}
aiGloballyEnabled={aiGloballyEnabled}
onToggleAiGloballyEnabled={handleToggleAiGloballyEnabled}
aiPrivateEndpoints={aiPrivateEndpoints}
onSaveAiPrivateEndpoints={handleSaveAiPrivateEndpoints}
handleToggleSessionSharingGloballyEnabled={
handleToggleSessionSharingGloballyEnabled
}
@@ -968,6 +1148,8 @@ export function AdminSettingsPanel({
handleSaveLogLevel={handleSaveLogLevel}
tailscaleApiKey={tailscaleApiKey}
setTailscaleApiKey={setTailscaleApiKey}
tailscaleApiBaseUrl={tailscaleApiBaseUrl}
setTailscaleApiBaseUrl={setTailscaleApiBaseUrl}
handleSaveTailscaleApiKey={handleSaveTailscaleApiKey}
/>
@@ -1002,6 +1184,12 @@ export function AdminSettingsPanel({
setUnlinkAccountTarget={setUnlinkAccountTarget}
setUnlinkAccountOpen={setUnlinkAccountOpen}
onManageUser={setManageUser}
search={userSearch}
onSearchChange={setUserSearch}
page={userPage}
pageSize={USERS_PAGE_SIZE}
total={userTotal}
onPageChange={setUserPage}
/>
<AdminSessionsSection
@@ -1037,6 +1225,22 @@ export function AdminSettingsPanel({
handleSaveDefaults={handleSaveHostDefaults}
/>
<AdminImageStorageSection
open={openSections.has("image-storage")}
onToggle={() => toggle("image-storage")}
settings={imageStorageSettings}
setSettings={setImageStorageSettings}
localDir={imageStorageLocalDir}
setLocalDir={setImageStorageLocalDir}
instanceId={imageStorageInstanceId}
setInstanceId={setImageStorageInstanceId}
saving={imageStorageSaving}
testing={imageStorageTesting}
testResult={imageStorageTestResult}
onSave={() => void handleSaveImageStorage()}
onTest={() => void handleTestImageStorage()}
/>
<AdminDatabaseSection
open={openSections.has("database")}
onToggle={() => toggle("database")}
@@ -1093,6 +1297,15 @@ export function AdminSettingsPanel({
users={users}
/>
<AdminTouchInputSection
open={openSections.has("touch-input")}
onToggle={() => toggle("touch-input")}
settings={touchInputSettings}
setSettings={setTouchInputSettings}
onSave={() => void saveTouchInputSettings()}
onReset={resetTouchInputSettings}
/>
<AdminCreateUserDialog
open={createUserOpen}
onOpenChange={setCreateUserOpen}
+61 -1
View File
@@ -27,6 +27,10 @@ type GeneralSettingsSectionProps = {
analyticsLocked: boolean;
handleToggleAnalytics: () => void;
sessionSharingGloballyEnabled: boolean;
aiGloballyEnabled: boolean;
onToggleAiGloballyEnabled: () => void;
aiPrivateEndpoints: string[];
onSaveAiPrivateEndpoints: (hosts: string[]) => void;
handleToggleSessionSharingGloballyEnabled: () => void;
allowRegistration: boolean;
handleToggleRegistration: () => void;
@@ -59,6 +63,8 @@ type GeneralSettingsSectionProps = {
handleSaveLogLevel: (level: string) => void;
tailscaleApiKey: string;
setTailscaleApiKey: Dispatch<SetStateAction<string>>;
tailscaleApiBaseUrl: string;
setTailscaleApiBaseUrl: Dispatch<SetStateAction<string>>;
handleSaveTailscaleApiKey: () => void;
};
@@ -69,6 +75,10 @@ export function AdminGeneralSettingsSection({
analyticsLocked,
handleToggleAnalytics,
sessionSharingGloballyEnabled,
aiGloballyEnabled,
onToggleAiGloballyEnabled,
aiPrivateEndpoints,
onSaveAiPrivateEndpoints,
handleToggleSessionSharingGloballyEnabled,
allowRegistration,
handleToggleRegistration,
@@ -101,6 +111,8 @@ export function AdminGeneralSettingsSection({
handleSaveLogLevel,
tailscaleApiKey,
setTailscaleApiKey,
tailscaleApiBaseUrl,
setTailscaleApiBaseUrl,
handleSaveTailscaleApiKey,
}: GeneralSettingsSectionProps) {
const { t } = useTranslation();
@@ -136,6 +148,40 @@ export function AdminGeneralSettingsSection({
onToggle={handleToggleSessionSharingGloballyEnabled}
/>
</SettingRow>
<SettingRow
label={t("admin.aiGloballyEnabled")}
description={t("admin.aiGloballyEnabledDesc")}
>
<AdminToggle
on={aiGloballyEnabled}
onToggle={onToggleAiGloballyEnabled}
/>
</SettingRow>
{aiGloballyEnabled && (
// Full-width rather than a SettingRow: the panel is narrow, and an
// inline field here squeezes the label down to a word per line.
<div className="flex flex-col gap-1.5 py-2">
<span className="text-xs font-medium">
{t("admin.aiPrivateEndpoints")}
</span>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("admin.aiPrivateEndpointsDesc")}
</span>
<Input
className="rounded-none"
defaultValue={aiPrivateEndpoints.join(", ")}
placeholder="localhost, 127.0.0.1"
onBlur={(event) =>
onSaveAiPrivateEndpoints(
event.target.value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean),
)
}
/>
</div>
)}
<SettingRow
label={t("admin.allowRegistration")}
description={t("admin.allowRegistrationDesc")}
@@ -362,7 +408,7 @@ export function AdminGeneralSettingsSection({
type="password"
value={tailscaleApiKey}
onChange={(e) => setTailscaleApiKey(e.target.value)}
placeholder="tskey-api-..."
placeholder="tskey-api-... / hskey-api-..."
className="text-sm"
/>
<Button
@@ -374,6 +420,20 @@ export function AdminGeneralSettingsSection({
{t("common.save")}
</Button>
</div>
<div className="flex flex-col gap-1.5 mt-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.tailscaleApiBaseUrl")}
</label>
<span className="text-[10px] text-muted-foreground">
{t("admin.tailscaleApiBaseUrlDescription")}
</span>
<Input
value={tailscaleApiBaseUrl}
onChange={(e) => setTailscaleApiBaseUrl(e.target.value)}
placeholder="https://api.tailscale.com/api/v2"
className="text-sm"
/>
</div>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
+170
View File
@@ -0,0 +1,170 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Hand } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { SettingRow } from "@/components/section-card";
import type {
TouchInputNumericKey,
TouchInputSettings,
} from "@/types/touch-input-settings";
import { TOUCH_INPUT_NUMERIC_BOUNDS } from "@/types/touch-input-settings";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
const fields: Array<{
key: TouchInputNumericKey;
label: string;
unit: string;
}> = [
{ key: "dragThresholdPx", label: "touchDragThreshold", unit: "px" },
{ key: "maxWheelDeltaPx", label: "touchMaxWheelDelta", unit: "px" },
{
key: "momentumSampleWindowMs",
label: "touchMomentumSampleWindow",
unit: "ms",
},
{
key: "releaseGracePeriodMs",
label: "touchReleaseGracePeriod",
unit: "ms",
},
{
key: "minimumVelocityPxPerMs",
label: "touchMinimumVelocity",
unit: "px/ms",
},
{
key: "maximumVelocityPxPerMs",
label: "touchMaximumVelocity",
unit: "px/ms",
},
{ key: "maximumDurationMs", label: "touchMaximumDuration", unit: "ms" },
{ key: "maximumTravelPx", label: "touchMaximumTravel", unit: "px" },
{ key: "decayTimeMs", label: "touchDecayTime", unit: "ms" },
{ key: "pixelsPerTick", label: "touchPixelsPerTick", unit: "px" },
{
key: "maximumFrameIntervalMs",
label: "touchMaximumFrameInterval",
unit: "ms",
},
{
key: "maximumTicksPerFrame",
label: "touchMaximumTicksPerFrame",
unit: "",
},
];
export function AdminTouchInputSection({
open,
onToggle,
settings,
setSettings,
onSave,
onReset,
}: {
open: boolean;
onToggle: () => void;
settings: TouchInputSettings;
setSettings: (settings: TouchInputSettings) => void;
onSave: () => void;
onReset: () => void;
}) {
const { t } = useTranslation();
const [advancedOpen, setAdvancedOpen] = useState(false);
return (
<AccordionSection
label={t("admin.sectionTouchInput")}
icon={<Hand className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<div className="flex flex-col gap-0 pt-2">
<SettingRow
label={t("admin.touchEnabled")}
description={t("admin.touchEnabledDesc")}
>
<AdminToggle
on={settings.enabled}
onToggle={() =>
setSettings({ ...settings, enabled: !settings.enabled })
}
/>
</SettingRow>
<SettingRow
label={t("admin.touchMomentumEnabled")}
description={t("admin.touchMomentumEnabledDesc")}
>
<AdminToggle
on={settings.momentumEnabled}
onToggle={() =>
setSettings({
...settings,
momentumEnabled: !settings.momentumEnabled,
})
}
disabled={!settings.enabled}
/>
</SettingRow>
<button
type="button"
className="flex items-center gap-2 py-3 text-left text-xs font-semibold text-foreground"
onClick={() => setAdvancedOpen((value) => !value)}
>
<ChevronDown
className={`size-3.5 transition-transform ${advancedOpen ? "rotate-180" : ""}`}
/>
{t("admin.touchAdvanced")}
</button>
{advancedOpen && (
<div className="grid grid-cols-1 gap-3 pb-3 sm:grid-cols-2">
{fields.map(({ key, label, unit }) => {
const bounds = TOUCH_INPUT_NUMERIC_BOUNDS[key];
return (
<label key={key} className="flex flex-col gap-1 text-xs">
<span className="font-medium text-foreground">
{t(`admin.${label}`)}
</span>
<div className="flex items-center gap-2">
<Input
aria-label={t(`admin.${label}`)}
type="number"
min={bounds.min}
max={bounds.max}
step={bounds.step}
value={settings[key]}
onChange={(event) =>
setSettings({
...settings,
[key]: Number(event.target.value),
})
}
className="h-8 text-xs"
/>
{unit && (
<span className="w-10 text-muted-foreground">{unit}</span>
)}
</div>
</label>
);
})}
</div>
)}
<div className="flex gap-2 border-t border-border pt-3">
<Button
size="sm"
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={onSave}
>
{t("common.save")}
</Button>
<Button size="sm" variant="outline" onClick={onReset}>
{t("admin.touchResetDefaults")}
</Button>
</div>
</div>
</AccordionSection>
);
}
+7 -2
View File
@@ -41,9 +41,14 @@ import {
assignRoleToUser,
removeRoleFromUser,
} from "@/main-axios";
import type { ApiKey, CreatedApiKey, Role, UserRole } from "@/main-axios";
import {
type ApiKey,
type CreatedApiKey,
type Role,
type SSHHostWithStatus,
type UserRole,
} from "@/main-axios";
import type { Host, Credential } from "@/types/ui-types";
import type { SSHHostWithStatus } from "@/main-axios";
import { CredentialEditorView } from "./CredentialEditorView";
import { HostEditor } from "./HostEditor";
import { mapCredentials, sshHostToHost } from "./HostManagerData";
+11 -2
View File
@@ -155,8 +155,17 @@ export function AlertsPanel() {
try {
await testNotificationChannel(id);
toast.success(t("alerts.testSent", "Test notification sent"));
} catch {
toast.error(t("alerts.testFailed", "Test notification failed"));
} catch (err) {
const detail = err instanceof Error ? err.message : undefined;
toast.error(
detail
? t(
"alerts.testFailedWithReason",
"Test notification failed: {{reason}}",
{ reason: detail },
)
: t("alerts.testFailed", "Test notification failed"),
);
}
}
+147 -79
View File
@@ -3,27 +3,19 @@ import { useTranslation } from "react-i18next";
import {
Bell,
Check,
Clock,
Fingerprint,
Hammer,
KeyRound,
LayoutPanelLeft,
LogOut,
Network,
Play,
Plug,
ScrollText,
Server,
PanelRight,
Settings,
SlidersHorizontal,
Usb,
SquareArrowOutUpRight,
User,
Zap,
} from "lucide-react";
import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
import { getAlertFirings } from "@/api/alerts-api";
import { isElectron } from "@/lib/electron";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import { visibleRailItems } from "./rail-items";
import { useAiAvailability } from "@/hooks/use-ai-availability";
export type RailView =
| "hosts"
@@ -36,7 +28,11 @@ export type RailView =
| "session-logs"
| "user-profile"
| "admin-settings"
| "alerts";
| "alerts"
| "automations"
| "ai"
| "fleets"
| "workspaces";
export type HideableRailView =
| Exclude<RailView, "user-profile" | "admin-settings">
@@ -50,6 +46,8 @@ type RailItem =
icon: React.ReactNode;
title: string;
dot?: boolean;
promotable?: boolean;
rightDockable?: boolean;
}
| { kind: "tab"; tabType: TabType; icon: React.ReactNode; title: string }
| { kind: "separator" };
@@ -59,70 +57,33 @@ function buildRailButtons(
t: (key: string) => string,
hidden: Set<string>,
): RailItem[] {
const all: RailItem[] = [
{ view: "hosts", icon: <Server size={16} />, title: t("nav.hosts") },
{
view: "credentials",
icon: <KeyRound size={16} />,
title: t("nav.credentials"),
},
{ kind: "separator" },
{
view: "termix-id",
icon: <Fingerprint size={16} />,
title: t("nav.termixId"),
},
{ kind: "separator" },
{
view: "connections",
icon: <Plug size={16} />,
title: t("nav.connections"),
},
{ kind: "separator" },
{
view: "quick-connect",
icon: <Zap size={16} />,
title: t("nav.quickConnect"),
},
{ kind: "separator" },
{
view: "serial",
icon: <Usb size={16} />,
title: t("nav.serial"),
},
{ kind: "separator" },
{ view: "ssh-tools", icon: <Hammer size={16} />, title: t("nav.sshTools") },
{ kind: "separator" },
{ view: "snippets", icon: <Play size={16} />, title: t("nav.snippets") },
{ kind: "separator" },
{ view: "history", icon: <Clock size={16} />, title: t("nav.history") },
{ kind: "separator" },
{
view: "session-logs",
icon: <ScrollText size={16} />,
title: t("nav.sessionLogs"),
},
{ kind: "separator" },
{
view: "split-screen",
icon: <LayoutPanelLeft size={16} />,
title: t("nav.splitScreen"),
dot: splitMode !== "none",
},
{ kind: "separator" },
{
kind: "tab",
tabType: "network_graph" as TabType,
icon: <Network size={16} />,
title: t("nav.networkGraph"),
},
{ kind: "separator" },
];
const all: RailItem[] = [];
for (const item of visibleRailItems()) {
const Icon = item.icon;
if (item.kind === "tab") {
all.push({
kind: "tab",
tabType: item.id as TabType,
icon: <Icon size={16} />,
title: t(item.labelKey),
});
} else {
all.push({
view: item.id as RailView,
icon: <Icon size={16} />,
title: t(item.labelKey),
dot: item.id === "split-screen" ? splitMode !== "none" : undefined,
promotable: item.promotable,
rightDockable: item.rightDockable,
});
}
if (item.separatorAfter) all.push({ kind: "separator" });
}
// Filter out hidden items, then collapse consecutive/leading/trailing separators
const filtered = all.filter((item) => {
if (item.kind === "separator") return true;
if (item.kind === "tab") return !hidden.has(item.tabType);
if ("tabType" in item) return !hidden.has(item.tabType);
return !hidden.has(item.view);
});
@@ -152,6 +113,7 @@ export function AppRail({
isAdmin,
onRailClick,
onOpenTab,
onOpenInRightDock,
onLogout,
}: {
railView: RailView;
@@ -161,6 +123,7 @@ export function AppRail({
isAdmin: boolean;
onRailClick: (view: RailView) => void;
onOpenTab?: (type: TabType) => void;
onOpenInRightDock?: (view: RailView) => void;
onLogout: () => void;
}) {
const { t } = useTranslation();
@@ -170,6 +133,14 @@ export function AppRail({
readRailPreference("expandAppRailOnHover"),
);
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
// Which promotable item was right-clicked, so the menu can offer to open it
// as a tab. Null when the right-click landed on empty rail space.
const [menuTarget, setMenuTarget] = useState<{
view: RailView;
title: string;
promotable?: boolean;
rightDockable?: boolean;
} | null>(null);
const [unreadAlerts, setUnreadAlerts] = useState(0);
useEffect(() => {
@@ -252,6 +223,8 @@ export function AppRail({
};
}, [menuPos]);
const { userEnabled: aiEnabled } = useAiAvailability();
useEffect(() => {
const handler = () => {
try {
@@ -299,9 +272,13 @@ export function AppRail({
}, []);
const railExpanded = pinned || (expandOnHover && hovered);
const effectiveHiddenTabs = isRemoteSyncConnected
? hiddenTabs
: new Set([...hiddenTabs, "termix-id"]);
const effectiveHiddenTabs = new Set([
...hiddenTabs,
...(isRemoteSyncConnected ? [] : ["termix-id"]),
// The assistant is hidden until an admin has enabled it instance-wide and
// the user has said yes, so someone who declined never sees the entry.
...(aiEnabled ? [] : ["ai"]),
]);
const railButtons = buildRailButtons(splitMode, t, effectiveHiddenTabs);
const togglePinned = () => {
@@ -329,6 +306,9 @@ export function AppRail({
x: Math.min(e.clientX, window.innerWidth - MENU_W - 8),
y: Math.min(e.clientY, window.innerHeight - MENU_H - 8),
});
if (!(e.target as HTMLElement).closest("[data-rail-promotable]")) {
setMenuTarget(null);
}
}}
>
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
@@ -339,7 +319,7 @@ export function AppRail({
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
/>
) : item.kind === "tab" ? (
) : "tabType" in item ? (
<button
key={item.tabType}
onClick={() => onOpenTab?.(item.tabType)}
@@ -363,7 +343,35 @@ export function AppRail({
) : (
<button
key={item.view}
onClick={() => onRailClick(item.view)}
onClick={(e) => {
if (item.promotable && (e.ctrlKey || e.metaKey)) {
onOpenTab?.(item.view as TabType);
return;
}
onRailClick(item.view);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.view as TabType);
}}
onContextMenu={() => {
if (item.promotable || item.rightDockable)
setMenuTarget({
view: item.view,
title: item.title,
promotable: item.promotable,
rightDockable: item.rightDockable,
});
}}
data-rail-promotable={
item.promotable || item.rightDockable ? "" : undefined
}
title={
item.promotable
? `${item.title}\n${t("nav.openAsTabHint")}`
: item.title
}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.view
@@ -398,6 +406,7 @@ export function AppRail({
view: "alerts" as RailView,
icon: <Bell size={16} />,
title: t("nav.alerts"),
promotable: true,
},
{
view: "user-profile" as RailView,
@@ -416,7 +425,33 @@ export function AppRail({
].map((item) => (
<button
key={item.view}
onClick={() => onRailClick(item.view)}
onClick={(e) => {
if (item.promotable && (e.ctrlKey || e.metaKey)) {
onOpenTab?.(item.view as TabType);
return;
}
onRailClick(item.view);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.view as TabType);
}}
onContextMenu={() => {
if (item.promotable)
setMenuTarget({
view: item.view,
title: item.title,
promotable: item.promotable,
rightDockable: true,
});
}}
data-rail-promotable={item.promotable ? "" : undefined}
title={
item.promotable
? `${item.title}\n${t("nav.openAsTabHint")}`
: item.title
}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.view
@@ -494,6 +529,39 @@ export function AppRail({
style={{ position: "fixed", left: menuPos.x, top: menuPos.y }}
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[190px]"
>
{menuTarget && (
<>
{menuTarget.promotable && (
<button
onClick={() => {
onOpenTab?.(menuTarget.view as TabType);
setMenuPos(null);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="shrink-0 w-3 flex items-center justify-center">
<SquareArrowOutUpRight className="size-3" />
</span>
{t("nav.openAsTab")}
</button>
)}
{menuTarget.rightDockable && (
<button
onClick={() => {
onOpenInRightDock?.(menuTarget.view);
setMenuPos(null);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="shrink-0 w-3 flex items-center justify-center">
<PanelRight className="size-3" />
</span>
{t("nav.openInRightDock")}
</button>
)}
<div className="h-px bg-border my-1" />
</>
)}
<button
onClick={togglePinned}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
+675
View File
@@ -0,0 +1,675 @@
import { getErrorMessage } from "../lib/error-message.js";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
ArrowLeft,
Copy,
FlaskConical,
Loader2,
Play,
Plus,
Trash2,
Workflow,
} from "lucide-react";
import { Button } from "@/components/button";
import { Badge } from "@/components/badge";
import {
createAutomation,
deleteAutomation,
listAutomationRunSteps,
listAutomationRuns,
listAutomations,
runAutomation,
updateAutomation,
type AutomationRow,
type AutomationRunRow,
type AutomationRunStepRow,
} from "@/api/automations-api";
import {
deleteNotificationChannel,
getNotificationChannels,
testNotificationChannel,
type NotificationChannel,
} from "@/api/alerts-api";
import { NotificationChannelDialog } from "./NotificationChannelDialog";
import { getSnippets } from "@/api/snippets-api";
import { getSSHHosts } from "@/api/ssh-host-management-api";
import { listFleets } from "@/api/fleets-api";
import {
AutomationEditor,
emptyDraft,
type AutomationDraft,
} from "./automations/AutomationEditor";
import {
EMPTY_EDITOR_OPTIONS,
type AutomationEditorOptions,
} from "./automations/editor-types";
type PanelTab = "automations" | "runs" | "channels";
const STATUS_CLASS: Record<string, string> = {
success: "text-green-500",
failed: "text-destructive",
timeout: "text-destructive",
running: "text-foreground",
skipped: "text-muted-foreground",
cancelled: "text-muted-foreground",
};
function timeAgo(iso: string | null): string {
if (!iso) return "";
const ms = Date.now() - new Date(iso).getTime();
if (!Number.isFinite(ms)) return "";
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
export function AutomationsPanel({
active = true,
onEditingChange,
}: {
active?: boolean;
onEditingChange?: (editing: boolean) => void;
}) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
const [tab, setTab] = useState<PanelTab>("automations");
const [automations, setAutomations] = useState<AutomationRow[]>([]);
const [runs, setRuns] = useState<AutomationRunRow[]>([]);
const [loading, setLoading] = useState(false);
const [options, setOptions] =
useState<AutomationEditorOptions>(EMPTY_EDITOR_OPTIONS);
const [editorOpen, setEditorOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [draft, setDraft] = useState<AutomationDraft>(emptyDraft());
const [saving, setSaving] = useState(false);
const [webhookToken, setWebhookToken] = useState<string | null>(null);
const [expandedRun, setExpandedRun] = useState<number | null>(null);
const [runSteps, setRunSteps] = useState<AutomationRunStepRow[]>([]);
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [channelDialogOpen, setChannelDialogOpen] = useState(false);
const [editingChannel, setEditingChannel] =
useState<NotificationChannel | null>(null);
const loadAutomations = useCallback(async () => {
setLoading(true);
try {
setAutomations(await listAutomations());
} catch {
// A failed load leaves the previous list on screen.
} finally {
setLoading(false);
}
}, []);
const loadRuns = useCallback(async () => {
try {
setRuns(await listAutomationRuns({ limit: 100 }));
} catch {
// Non-fatal.
}
}, []);
const loadOptions = useCallback(async () => {
const [hosts, snippets, channelsResult, fleets] = await Promise.allSettled([
getSSHHosts({ includeStatus: false }),
getSnippets(),
getNotificationChannels(),
listFleets(),
]);
if (channelsResult.status === "fulfilled") {
setChannels(channelsResult.value);
}
setOptions({
hosts:
hosts.status === "fulfilled"
? hosts.value.map((host) => ({
id: host.id,
name: host.name || host.ip,
}))
: [],
snippets:
snippets.status === "fulfilled"
? snippets.value.map((s) => ({
id: s.id,
// SnippetRow only types its id; the rest is narrowed by callers.
name: typeof s.name === "string" ? s.name : `#${s.id}`,
}))
: [],
channels:
channelsResult.status === "fulfilled"
? channelsResult.value.map((c) => ({ id: c.id, name: c.name }))
: [],
fleets:
fleets.status === "fulfilled"
? fleets.value.map((f) => ({ id: f.id, name: f.name }))
: [],
});
}, []);
useEffect(() => {
if (!active) return;
void loadAutomations();
void loadOptions();
}, [active, loadAutomations, loadOptions]);
useEffect(() => {
if (active && tab === "runs") void loadRuns();
}, [active, tab, loadRuns]);
// Widens the sidebar while the editor is open, and releases it again on the
// way out or when the user switches to another rail destination.
useEffect(() => {
if (active) onEditingChange?.(editorOpen);
}, [active, editorOpen, onEditingChange]);
useEffect(() => {
return () => onEditingChange?.(false);
}, [onEditingChange]);
function closeEditor() {
setWebhookToken(null);
setEditorOpen(false);
}
function openCreate() {
setEditingId(null);
setDraft(emptyDraft());
setWebhookToken(null);
setEditorOpen(true);
}
function openEdit(row: AutomationRow) {
setEditingId(row.id);
setDraft({
name: row.name,
description: row.description ?? "",
enabled: !!row.enabled,
concurrencyPolicy: row.concurrency_policy ?? "skip",
definition: row.definition ?? emptyDraft().definition,
});
setWebhookToken(null);
setEditorOpen(true);
}
async function save() {
if (!draft.name.trim()) {
toast.error(t(`${base}.nameRequired`));
return;
}
setSaving(true);
try {
if (editingId === null) {
const created = await createAutomation({
name: draft.name.trim(),
description: draft.description || null,
enabled: draft.enabled,
concurrencyPolicy: draft.concurrencyPolicy,
definition: draft.definition,
});
toast.success(t(`${base}.created`));
if (created.webhookToken) {
setWebhookToken(created.webhookToken);
} else {
setEditorOpen(false);
}
} else {
await updateAutomation(editingId, {
name: draft.name.trim(),
description: draft.description || null,
enabled: draft.enabled,
concurrencyPolicy: draft.concurrencyPolicy,
definition: draft.definition,
});
toast.success(t(`${base}.updated`));
setEditorOpen(false);
}
await loadAutomations();
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
} finally {
setSaving(false);
}
}
async function remove(row: AutomationRow) {
if (!confirm(t(`${base}.deleteConfirm`))) return;
try {
await deleteAutomation(row.id);
toast.success(t(`${base}.deleted`));
await loadAutomations();
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
}
}
async function run(row: AutomationRow, dryRun: boolean) {
try {
const outcome = await runAutomation(row.id, { dryRun });
toast.success(t(`${base}.runFinished`, { status: outcome.status }));
await loadAutomations();
if (tab === "runs") await loadRuns();
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
}
}
async function testChannel(channel: NotificationChannel) {
try {
// Throws with the reason when delivery fails.
await testNotificationChannel(channel.id);
toast.success(t(`${base}.channelTestSent`));
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
}
}
async function removeChannel(channel: NotificationChannel) {
if (!confirm(t(`${base}.deleteChannelConfirm`))) return;
try {
await deleteNotificationChannel(channel.id);
toast.success(t(`${base}.channelDeleted`));
await loadOptions();
} catch (error) {
toast.error(getErrorMessage(error, String(error)));
}
}
async function toggleRun(runId: number) {
if (expandedRun === runId) {
setExpandedRun(null);
return;
}
setExpandedRun(runId);
try {
setRunSteps(await listAutomationRunSteps(runId));
} catch {
setRunSteps([]);
}
}
// Editing takes over the whole panel and widens the sidebar, the same way the
// host manager does, rather than opening a dialog over the app.
if (editorOpen) {
const webhookUrl = webhookToken
? `${window.location.origin}/automations/webhook/${webhookToken}`
: "";
return (
<div className="flex flex-col h-full min-h-0">
<button
onClick={closeEditor}
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50 shrink-0"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>{t(`${base}.backToAutomations`)}</span>
{editingId !== null && (
<span
className="ml-auto font-semibold text-foreground truncate max-w-[200px]"
title={draft.name}
>
{draft.name}
</span>
)}
</button>
<div className="flex-1 min-h-0 overflow-y-auto p-3">
{webhookToken ? (
<div className="space-y-2">
<p className="text-sm">{t(`${base}.webhookTokenTitle`)}</p>
<p className="text-xs text-muted-foreground">
{t(`${base}.webhookTokenDescription`)}
</p>
<div className="flex gap-2">
<code className="flex-1 p-2 bg-muted text-xs break-all">
{webhookUrl}
</code>
<Button
size="sm"
variant="outline"
className="rounded-none border-border shrink-0"
onClick={() => {
navigator.clipboard?.writeText(webhookUrl);
toast.success(t(`${base}.copied`));
}}
>
<Copy size={14} />
</Button>
</div>
</div>
) : (
<AutomationEditor
draft={draft}
options={options}
onChange={setDraft}
/>
)}
</div>
<div className="flex items-center gap-2 p-3 border-t border-border shrink-0">
{webhookToken ? (
<Button
variant="outline"
className="rounded-none ml-auto h-9 px-6 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={closeEditor}
>
{t("common.close")}
</Button>
) : (
<>
<Button
variant="outline"
className="rounded-none border-border h-9 px-5"
onClick={closeEditor}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="rounded-none ml-auto h-9 px-6 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={saving}
onClick={save}
>
{saving && (
<Loader2 size={14} className="mr-1.5 animate-spin" />
)}
{t(`${base}.save`)}
</Button>
</>
)}
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center gap-1 p-2 border-b border-border">
{(["automations", "runs", "channels"] as PanelTab[]).map((key) => (
<Button
key={key}
variant={tab === key ? "outline" : "ghost"}
size="sm"
className={`min-w-0 flex-shrink rounded-none h-7 px-2 text-xs ${
tab === key
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand"
: ""
}`}
onClick={() => setTab(key)}
>
<span className="truncate">
{t(
`${base}.${key === "automations" ? "tabAutomations" : key === "runs" ? "tabRuns" : "tabChannels"}`,
)}
</span>
</Button>
))}
<a
href="https://docs.termix.site/features/automations/overview"
target="_blank"
rel="noreferrer"
className="ml-auto shrink-0 text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</div>
<div className="flex-1 overflow-y-auto p-2">
{tab === "automations" && (
<>
<Button
size="sm"
variant="outline"
className="rounded-none w-full mb-2 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={openCreate}
>
<Plus size={14} className="mr-1" />
{t(`${base}.createAutomation`)}
</Button>
{loading && automations.length === 0 && (
<div className="flex justify-center p-4">
<Loader2 size={16} className="animate-spin" />
</div>
)}
{!loading && automations.length === 0 && (
<p className="text-xs text-muted-foreground p-2">
{t(`${base}.empty`)}
</p>
)}
<div className="space-y-1">
{automations.map((row) => (
<div
key={row.id}
className="border border-border p-2.5 hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<Workflow size={14} className="shrink-0" />
<button
className="flex-1 text-left text-sm truncate"
onClick={() => openEdit(row)}
>
{row.name}
</button>
{!row.enabled && (
<Badge variant="outline" className="text-[10px]">
off
</Badge>
)}
</div>
<div className="flex items-center gap-1 mt-1">
<span className="text-[11px] text-muted-foreground flex-1 truncate">
{row.definition
? t(
`${base}.triggerKinds.${row.definition.trigger.kind}`,
)
: ""}
{row.last_run_status
? ` · ${timeAgo(row.last_run_at)}`
: ""}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
title={t(`${base}.testRun`)}
onClick={() => run(row, true)}
>
<FlaskConical size={12} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
title={t(`${base}.runNow`)}
onClick={() => run(row, false)}
>
<Play size={12} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
onClick={() => remove(row)}
>
<Trash2 size={12} />
</Button>
</div>
</div>
))}
</div>
</>
)}
{tab === "runs" && (
<div className="space-y-1">
{runs.length === 0 && (
<p className="text-xs text-muted-foreground p-2">
{t(`${base}.emptyRuns`)}
</p>
)}
{runs.map((entry) => (
<div key={entry.id} className="border border-border">
<button
className="w-full text-left p-2.5 hover:bg-muted/40"
onClick={() => toggleRun(entry.id)}
>
<div className="flex items-center gap-2">
<span className="text-sm flex-1 truncate">
{entry.automation_name ?? `#${entry.automation_id}`}
</span>
{entry.dry_run ? (
<Badge variant="outline" className="text-[10px]">
{t(`${base}.dryRunBadge`)}
</Badge>
) : null}
<span
className={`text-[11px] ${STATUS_CLASS[entry.status] ?? ""}`}
>
{t(`${base}.statuses.${entry.status}`)}
</span>
</div>
<div className="text-[11px] text-muted-foreground">
{timeAgo(entry.started_at)}
{entry.duration_ms !== null
? ` · ${Math.round(entry.duration_ms / 100) / 10}s`
: ""}
</div>
{entry.error && (
<div className="text-[11px] text-destructive truncate">
{entry.error}
</div>
)}
</button>
{expandedRun === entry.id && (
<div className="border-t border-border p-2 space-y-1">
{runSteps.map((step) => (
<div key={step.id} className="text-[11px]">
<div className="flex gap-2">
<span className="text-muted-foreground">
{step.step_index + 1}.
</span>
<span className="flex-1">
{t(`${base}.stepTypes.${step.step_type}`, {
defaultValue: step.step_type,
})}
</span>
<span className={STATUS_CLASS[step.status] ?? ""}>
{step.status}
</span>
</div>
{step.output && (
<pre className="mt-1 p-1 bg-muted overflow-x-auto whitespace-pre-wrap break-all text-[10px]">
{step.output}
</pre>
)}
{step.error && (
<div className="text-destructive">{step.error}</div>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{tab === "channels" && (
<>
<Button
size="sm"
variant="outline"
className="rounded-none w-full mb-2 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditingChannel(null);
setChannelDialogOpen(true);
}}
>
<Plus size={14} className="mr-1" />
{t(`${base}.addChannel`)}
</Button>
{channels.length === 0 && (
<p className="text-xs text-muted-foreground p-2">
{t(`${base}.emptyChannels`)}
</p>
)}
<div className="space-y-1">
{channels.map((channel) => (
<div
key={channel.id}
className="border border-border p-2.5 hover:bg-muted/40"
>
<div className="flex items-center gap-2">
<button
className="flex-1 text-left text-sm truncate"
onClick={() => {
setEditingChannel(channel);
setChannelDialogOpen(true);
}}
>
{channel.name}
</button>
<Badge variant="outline" className="text-[10px] uppercase">
{channel.type}
</Badge>
</div>
<div className="flex items-center gap-1 mt-1">
<span className="flex-1" />
<Button
variant="ghost"
size="sm"
className="h-6 rounded-none text-[11px]"
onClick={() => testChannel(channel)}
>
{t(`${base}.testChannel`)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-none"
onClick={() => removeChannel(channel)}
>
<Trash2 size={12} />
</Button>
</div>
</div>
))}
</div>
</>
)}
</div>
<NotificationChannelDialog
open={channelDialogOpen}
onOpenChange={setChannelDialogOpen}
channel={editingChannel}
onSaved={() => {
setChannelDialogOpen(false);
void loadOptions();
}}
/>
</div>
);
}
export default AutomationsPanel;
@@ -0,0 +1,420 @@
import { useEffect, useMemo, useState } from "react";
import type React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/dialog";
import { SettingRow } from "@/components/section-card";
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
import {
CURSOR_STYLES,
TERMINAL_FONTS,
TERMINAL_THEMES,
} from "@/lib/terminal-themes";
import {
fromTriState,
toTriState,
type RemoteDesktopDefaults,
type TerminalDefaults,
type TriState,
} from "@/lib/connection-defaults";
const inputClass =
"h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring";
const labelClass =
"text-[10px] font-bold uppercase tracking-widest text-muted-foreground";
const RDP_TOGGLES = [
["forceLossless", "hosts.guac.forceLossless", "hosts.guac.forceLosslessDesc"],
["enableWallpaper", "hosts.guac.wallpaper", "hosts.guac.wallpaperDesc"],
[
"enableFontSmoothing",
"hosts.guac.fontSmoothing",
"hosts.guac.fontSmoothingDesc",
],
[
"enableDesktopComposition",
"hosts.guac.desktopComposition",
"hosts.guac.desktopCompositionDesc",
],
["disableAudio", "hosts.guac.disableAudio", "hosts.guac.disableAudioDesc"],
[
"enablePrinting",
"hosts.guac.enablePrinting",
"hosts.guac.enablePrintingDesc",
],
[
"enableDrive",
"hosts.guac.enableDriveRedirection",
"hosts.guac.enableDriveRedirectionDesc",
],
["disableCopy", "hosts.guac.disableCopy", "hosts.guac.disableCopyDesc"],
["disablePaste", "hosts.guac.disablePaste", "hosts.guac.disablePasteDesc"],
] as const;
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1.5">
<span className={labelClass}>{label}</span>
{children}
</div>
);
}
function TriStateSelect({
value,
onChange,
}: {
value?: boolean;
onChange: (value: boolean | undefined) => void;
}) {
const { t } = useTranslation();
return (
<select
className="h-7 w-28 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
value={toTriState(value)}
onChange={(e) => onChange(fromTriState(e.target.value as TriState))}
>
<option value="inherit">
{t("newUi.sidebar.connectionDefaults.inherit")}
</option>
<option value="on">{t("newUi.sidebar.connectionDefaults.on")}</option>
<option value="off">{t("newUi.sidebar.connectionDefaults.off")}</option>
</select>
);
}
export function ConnectionDefaultsSettings() {
const { t } = useTranslation();
const defaults = useConnectionDefaults();
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<"terminal" | "rdp">("terminal");
const [terminal, setTerminal] = useState<TerminalDefaults>({});
const [rdp, setRdp] = useState<RemoteDesktopDefaults>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!defaults.ready) return;
setTerminal(defaults.terminal);
setRdp(defaults.rdp);
}, [defaults.ready, defaults.terminal, defaults.rdp]);
const configuredCount = useMemo(() => {
const count = (source: Record<string, unknown>) =>
Object.values(source).filter((value) => value !== undefined).length;
return count(defaults.terminal) + count(defaults.rdp);
}, [defaults.terminal, defaults.rdp]);
const updateTerminal = <K extends keyof TerminalDefaults>(
key: K,
value: TerminalDefaults[K],
) => setTerminal((current) => ({ ...current, [key]: value }));
const updateRdp = <K extends keyof RemoteDesktopDefaults>(
key: K,
value: RemoteDesktopDefaults[K],
) => setRdp((current) => ({ ...current, [key]: value }));
async function save() {
setSaving(true);
try {
await Promise.all([
defaults.saveDefaults("terminal", terminal),
defaults.saveDefaults("rdp", rdp),
]);
toast.success(t("newUi.sidebar.connectionDefaults.saved"));
setOpen(false);
} catch {
toast.error(t("newUi.sidebar.connectionDefaults.saveFailed"));
} finally {
setSaving(false);
}
}
const inheritLabel = t("newUi.sidebar.connectionDefaults.inherit");
return (
<>
<SettingRow
label={t("newUi.sidebar.connectionDefaults.title")}
description={t("newUi.sidebar.connectionDefaults.description")}
badge={configuredCount > 0 ? String(configuredCount) : undefined}
>
<Button
variant="outline"
size="sm"
disabled={!defaults.ready}
onClick={() => setOpen(true)}
>
{t("newUi.sidebar.connectionDefaults.manage")}
</Button>
</SettingRow>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="text-base font-bold">
{t("newUi.sidebar.connectionDefaults.title")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.connectionDefaults.dialogDescription")}{" "}
<a
href="https://docs.termix.site/features/files-and-hosts/connection-defaults"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</DialogDescription>
</DialogHeader>
<div className="flex border-b border-border">
{(
[
["terminal", "newUi.sidebar.connectionDefaults.tabTerminal"],
["rdp", "newUi.sidebar.connectionDefaults.tabRemoteDesktop"],
] as const
).map(([id, labelKey]) => (
<button
key={id}
onClick={() => setTab(id)}
className={`px-3 py-2 text-xs font-medium border-b-2 -mb-px transition-colors ${
tab === id
? "border-accent-brand text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{t(labelKey)}
</button>
))}
</div>
<div className="max-h-[55vh] overflow-y-auto pr-1">
{tab === "terminal" ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label={t("hosts.fontFamilyLabel")}>
<select
className={inputClass}
value={terminal.fontFamily ?? ""}
onChange={(e) =>
updateTerminal("fontFamily", e.target.value || undefined)
}
>
<option value="">{inheritLabel}</option>
{TERMINAL_FONTS.map((font) => (
<option key={font.value} value={font.value}>
{font.label}
</option>
))}
</select>
</Field>
<Field label={t("hosts.colorTheme")}>
<select
className={inputClass}
value={terminal.theme ?? ""}
onChange={(e) =>
updateTerminal("theme", e.target.value || undefined)
}
>
<option value="">{inheritLabel}</option>
{Object.entries(TERMINAL_THEMES)
.filter(
([key]) =>
key !== "termixDark" && key !== "termixLight",
)
.map(([key, theme]) => (
<option key={key} value={key}>
{theme.name}
</option>
))}
</select>
</Field>
<Field label={t("hosts.fontSizeLabel")}>
<input
className={inputClass}
type="number"
min={8}
max={32}
value={terminal.fontSize ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"fontSize",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<Field label={t("hosts.lineHeightLabel")}>
<input
className={inputClass}
type="number"
min={0.8}
max={2}
step={0.05}
value={terminal.lineHeight ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"lineHeight",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<Field label={t("hosts.cursorStyleLabel")}>
<select
className={inputClass}
value={terminal.cursorStyle ?? ""}
onChange={(e) =>
updateTerminal(
"cursorStyle",
(e.target.value || undefined) as
TerminalDefaults["cursorStyle"] | undefined,
)
}
>
<option value="">{inheritLabel}</option>
{CURSOR_STYLES.map((style) => (
<option key={style.value} value={style.value}>
{style.label}
</option>
))}
</select>
</Field>
<Field label={t("hosts.scrollbackBufferLabel")}>
<input
className={inputClass}
type="number"
min={1000}
max={100000}
step={1000}
value={terminal.scrollback ?? ""}
placeholder={inheritLabel}
onChange={(e) =>
updateTerminal(
"scrollback",
e.target.value ? Number(e.target.value) : undefined,
)
}
/>
</Field>
<div className="sm:col-span-2">
<SettingRow
label={t("hosts.cursorBlinking")}
description={t("hosts.cursorBlinkingDesc")}
>
<TriStateSelect
value={terminal.cursorBlink}
onChange={(value) => updateTerminal("cursorBlink", value)}
/>
</SettingRow>
</div>
</div>
) : (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label={t("hosts.guac.colorDepth")}>
<select
className={inputClass}
value={rdp.colorDepth ?? ""}
onChange={(e) =>
updateRdp(
"colorDepth",
e.target.value ? Number(e.target.value) : undefined,
)
}
>
<option value="">{inheritLabel}</option>
<option value="16">16-bit</option>
<option value="24">24-bit</option>
<option value="32">32-bit</option>
</select>
</Field>
<Field label={t("hosts.guac.resizeMethod")}>
<select
className={inputClass}
value={rdp.resizeMethod ?? ""}
onChange={(e) =>
updateRdp("resizeMethod", e.target.value || undefined)
}
>
<option value="">{inheritLabel}</option>
<option value="display-update">
{t(
"newUi.sidebar.connectionDefaults.resizeDisplayUpdate",
)}
</option>
<option value="reconnect">
{t("newUi.sidebar.connectionDefaults.resizeReconnect")}
</option>
</select>
</Field>
</div>
<div>
{RDP_TOGGLES.map(([key, labelKey, descriptionKey]) => (
<SettingRow
key={key}
label={t(labelKey)}
description={t(descriptionKey)}
>
<TriStateSelect
value={rdp[key] as boolean | undefined}
onChange={(value) => updateRdp(key, value)}
/>
</SettingRow>
))}
</div>
</div>
)}
</div>
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
<Button
variant="ghost"
size="sm"
onClick={() => {
setTerminal({});
setRdp({});
}}
>
{t("newUi.sidebar.connectionDefaults.clearAll")}
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button
size="sm"
disabled={saving || !defaults.ready}
onClick={save}
>
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
+15 -13
View File
@@ -19,6 +19,7 @@ import {
TooltipTrigger,
} from "@/components/tooltip";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
const CONNECTION_TAB_TYPES: TabType[] = [
"terminal",
@@ -314,6 +315,8 @@ export function ConnectionsPanel({
const { t } = useTranslation();
const [now, setNow] = useState(Date.now());
const [activeSessions, setActiveSessions] = useState<ActiveSessionInfo[]>([]);
const activeSessionsRef = useRef(activeSessions);
activeSessionsRef.current = activeSessions;
const [search, setSearch] = useState("");
// Drag-to-reorder state
@@ -371,22 +374,21 @@ export function ConnectionsPanel({
usePageVisibleInterval(() => setNow(Date.now()), 15_000);
const refresh = useCallback(async () => {
try {
const sessions = await getActiveSessions();
setActiveSessions((prev) => {
const next = Array.isArray(sessions) ? sessions : [];
if (sessionsUnchanged(prev, next)) return prev;
return next;
});
} catch {
// silently ignore
const sessions = await getActiveSessions();
const next = Array.isArray(sessions) ? sessions : [];
const changed = !sessionsUnchanged(activeSessionsRef.current, next);
if (changed) {
activeSessionsRef.current = next;
setActiveSessions(next);
}
return changed;
}, []);
// Initial fetch + visibility-aware poll (hook fires once on mount).
usePageVisibleInterval(() => {
void refresh();
}, 5_000);
useAdaptivePolling(refresh, {
minIntervalMs: 5_000,
maxIntervalMs: 30_000,
stablePollsPerStep: 3,
});
// Global pointer listeners for drag reorder
useEffect(() => {
+1 -4
View File
@@ -19,8 +19,6 @@ import {
import type { Credential } from "@/types/ui-types";
import { FolderPathPicker } from "./FolderPathPicker";
type CredentialWithCertificate = Credential & { certPublicKey?: string };
export function CredentialEditorView({
credential,
activeTab,
@@ -51,8 +49,7 @@ export function CredentialEditorView({
: (credential?.password ?? ""),
publicKey: credential?.publicKey ?? "",
passphrase: credential?.passphrase ?? "",
certPublicKey:
(credential as CredentialWithCertificate | null)?.certPublicKey ?? "",
certPublicKey: credential?.certPublicKey ?? "",
}));
const { t } = useTranslation();
const [generatingKey, setGeneratingKey] = useState(false);
+223 -169
View File
@@ -5,11 +5,17 @@ import {
Check,
ExternalLink,
Filter,
GripVertical,
Plus,
Search,
SlidersHorizontal,
X,
} from "lucide-react";
import { toast } from "sonner";
import { HostManager } from "@/sidebar/HostManager";
import { useCredentialSidebarPreferences } from "@/sidebar/credential-tree/hooks/useCredentialSidebarPreferences";
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
import { CustomizeCredentialsSidebarPanel } from "@/sidebar/CustomizeCredentialsSidebarPanel";
import { Button } from "@/components/button";
import {
DropdownMenu,
@@ -20,16 +26,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
export type CredentialSortKey =
"default" | "name-asc" | "name-desc" | "username-asc" | "username-desc";
export type CredentialFilterState = {
type: ("password" | "key")[];
tags: string[];
};
const DEFAULT_FILTERS: CredentialFilterState = { type: [], tags: [] };
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
export function CredentialsPanel({
onEditingChange,
@@ -39,52 +36,60 @@ export function CredentialsPanel({
active?: boolean;
}) {
const { t } = useTranslation();
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
useCredentialSidebarPreferences();
const [search, setSearch] = useState("");
const [managerEditing, setManagerEditing] = useState(false);
const [allTags, setAllTags] = useState<string[]>([]);
const [sortKey, setSortKey] = useState<CredentialSortKey>(
() =>
(localStorage.getItem("credentialSortKey") as CredentialSortKey) ??
"default",
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
const sortKey = sidebarPrefs.sort.key;
const { arrangeLocked, toggleArrangeLock } = useArrangeLock(
"credentialSidebarArrangeLocked",
);
const [filterState, setFilterState] = useState<CredentialFilterState>(() => {
try {
const saved = localStorage.getItem("credentialFilterState");
return saved
? (JSON.parse(saved) as CredentialFilterState)
: DEFAULT_FILTERS;
} catch {
return DEFAULT_FILTERS;
}
});
const filterActive = Object.values(filterState).some((arr) => arr.length > 0);
const filterState = sidebarPrefs.filters;
const filterActive =
filterState.type.length > 0 || filterState.tags.length > 0;
function handleSortChange(key: CredentialSortKey) {
setSortKey(key);
localStorage.setItem("credentialSortKey", key);
updateSidebarPrefs((prev) => ({
...prev,
sort: { ...prev.sort, key },
}));
}
function handleFilterToggle<K extends keyof CredentialFilterState>(
function handleArrangeLockToggle() {
const unlocking = arrangeLocked;
toggleArrangeLock();
// Same reasoning as the hosts panel: reordering only sticks visually
// under manual sort, so unlocking switches to it.
if (unlocking && sortKey !== "manual") {
handleSortChange("manual");
toast.info(t("credentials.arrangeUnlockedSwitchedToManual"));
}
}
function handleFilterToggle<K extends keyof typeof filterState>(
group: K,
value: CredentialFilterState[K][number],
value: (typeof filterState)[K][number],
) {
setFilterState((prev) => {
const arr = prev[group] as string[];
updateSidebarPrefs((prev) => {
const arr = prev.filters[group] as string[];
const next = arr.includes(value as string)
? arr.filter((v) => v !== value)
: [...arr, value as string];
const updated = { ...prev, [group]: next };
localStorage.setItem("credentialFilterState", JSON.stringify(updated));
return updated as CredentialFilterState;
return {
...prev,
filters: { ...prev.filters, [group]: next },
};
});
}
function handleFilterClear() {
setFilterState(DEFAULT_FILTERS);
localStorage.setItem(
"credentialFilterState",
JSON.stringify(DEFAULT_FILTERS),
);
updateSidebarPrefs((prev) => ({
...prev,
filters: { type: [], tags: [] },
}));
}
function handleEditingChange(editing: boolean) {
@@ -114,152 +119,190 @@ export function CredentialsPanel({
)}
</div>
<div className="flex flex-wrap items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`size-7 ${sortKey !== "default" ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("credentials.sortCredentials")}
<div className="flex items-center gap-1.5 overflow-x-auto overflow-y-hidden toolbar-scrollbar">
<div className="flex items-center border border-border shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`size-7 ${sortKey !== "default" || !arrangeLocked ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("credentials.sortCredentials")}
>
<ArrowUpDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="text-xs min-w-[160px]"
>
<ArrowUpDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="text-xs min-w-[160px]"
>
<DropdownMenuItem
onClick={() => handleSortChange("default")}
className="flex items-center gap-1.5"
>
{sortKey === "default" ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t("credentials.sortDefault")}
</DropdownMenuItem>
<DropdownMenuSeparator />
{(["name-asc", "name-desc"] as const).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => handleSortChange(key)}
onClick={() => handleSortChange("default")}
className="flex items-center gap-1.5"
>
{sortKey === key ? (
{sortKey === "default" ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t(
`credentials.sort${key === "name-asc" ? "NameAsc" : "NameDesc"}`,
)}
{t("credentials.sortDefault")}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{(["username-asc", "username-desc"] as const).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => handleSortChange(key)}
className="flex items-center gap-1.5"
>
{sortKey === key ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t(
`credentials.sort${key === "username-asc" ? "UsernameAsc" : "UsernameDesc"}`,
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`size-7 ${filterActive ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("credentials.filterCredentials")}
>
<Filter className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="text-xs min-w-[180px]"
>
{filterActive && (
<>
<DropdownMenuSeparator />
{(["name-asc", "name-desc"] as const).map((key) => (
<DropdownMenuItem
onClick={handleFilterClear}
className="flex items-center gap-1.5 text-accent-brand"
key={key}
onClick={() => handleSortChange(key)}
className="flex items-center gap-1.5"
>
<X className="size-3 shrink-0" />
{t("credentials.filterClearAll")}
{sortKey === key ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t(
`credentials.sort${key === "name-asc" ? "NameAsc" : "NameDesc"}`,
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel>
{t("credentials.filterTypeGroup")}
</DropdownMenuLabel>
{(["password", "key"] as const).map((val) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.type.includes(val)}
onCheckedChange={() => handleFilterToggle("type", val)}
onSelect={(e) => e.preventDefault()}
))}
<DropdownMenuSeparator />
{(["username-asc", "username-desc"] as const).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => handleSortChange(key)}
className="flex items-center gap-1.5"
>
{sortKey === key ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t(
`credentials.sort${key === "username-asc" ? "UsernameAsc" : "UsernameDesc"}`,
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleSortChange("manual")}
className="flex items-center gap-1.5"
>
{t(
`credentials.filterType${val.charAt(0).toUpperCase() + val.slice(1)}`,
{sortKey === "manual" ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<GripVertical className="size-3 shrink-0 text-muted-foreground/40" />
)}
{t("credentials.sortManual")}
</DropdownMenuItem>
<DropdownMenuCheckboxItem
checked={!arrangeLocked}
onCheckedChange={handleArrangeLockToggle}
onSelect={(event) => event.preventDefault()}
>
{t("credentials.arrangeUnlocked")}
</DropdownMenuCheckboxItem>
))}
{allTags.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
{t("credentials.filterTagsGroup")}
</DropdownMenuLabel>
{allTags.map((tag) => (
<DropdownMenuCheckboxItem
key={tag}
checked={filterState.tags.includes(tag)}
onCheckedChange={() => handleFilterToggle("tags", tag)}
onSelect={(e) => e.preventDefault()}
</DropdownMenuContent>
</DropdownMenu>
<div className="w-px self-stretch bg-border" />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`size-7 ${filterActive ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("credentials.filterCredentials")}
>
<Filter className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="text-xs min-w-[180px]"
>
{filterActive && (
<>
<DropdownMenuItem
onClick={handleFilterClear}
className="flex items-center gap-1.5 text-accent-brand"
>
{tag}
</DropdownMenuCheckboxItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<a
href="https://docs.termix.site/features/files-and-hosts/credentials"
target="_blank"
rel="noreferrer"
title={t("hosts.docsLink")}
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground shrink-0 transition-colors ml-auto"
>
<ExternalLink className="size-3.5" />
</a>
<button
onClick={() =>
window.dispatchEvent(
new CustomEvent("host-manager:add-credential"),
)
}
title={t("credentials.addCredential")}
className="flex items-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 border border-accent-brand/30 rounded-sm shrink-0 transition-colors"
>
<Plus className="size-3 shrink-0" />
{t("credentials.addCredential")}
</button>
<X className="size-3 shrink-0" />
{t("credentials.filterClearAll")}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel>
{t("credentials.filterTypeGroup")}
</DropdownMenuLabel>
{(["password", "key"] as const).map((val) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.type.includes(val)}
onCheckedChange={() => handleFilterToggle("type", val)}
onSelect={(e) => e.preventDefault()}
>
{t(
`credentials.filterType${val.charAt(0).toUpperCase() + val.slice(1)}`,
)}
</DropdownMenuCheckboxItem>
))}
{allTags.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
{t("credentials.filterTagsGroup")}
</DropdownMenuLabel>
{allTags.map((tag) => (
<DropdownMenuCheckboxItem
key={tag}
checked={filterState.tags.includes(tag)}
onCheckedChange={() =>
handleFilterToggle("tags", tag)
}
onSelect={(e) => e.preventDefault()}
>
{tag}
</DropdownMenuCheckboxItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
<div className="w-px self-stretch bg-border" />
<a
href="https://docs.termix.site/features/files-and-hosts/credentials"
target="_blank"
rel="noreferrer"
title={t("hosts.docsLink")}
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground shrink-0 transition-colors"
>
<ExternalLink className="size-3.5" />
</a>
</div>
<div className="flex items-center border border-border shrink-0">
<button
onClick={() => setCustomizePanelOpen(true)}
title={t("credentials.customizeSidebar")}
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
>
<SlidersHorizontal className="size-3.5" />
</button>
</div>
<div className="flex items-center border border-accent-brand/30 ml-auto shrink-0">
<button
onClick={() =>
window.dispatchEvent(
new CustomEvent("host-manager:add-credential"),
)
}
title={t("credentials.addCredential")}
className="flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 transition-colors"
>
<Plus className="size-3 shrink-0" />
<span className="hidden min-[280px]:inline">
{t("credentials.addCredential")}
</span>
</button>
</div>
</div>
</div>
)}
@@ -269,12 +312,23 @@ export function CredentialsPanel({
hideListHeader
externalSearch={managerEditing ? undefined : search}
externalSort={sortKey}
externalArrangeLocked={arrangeLocked}
externalFilter={filterState}
density={sidebarPrefs.display.density}
trayTrigger={sidebarPrefs.display.trayTrigger}
showTags={sidebarPrefs.display.showTags}
onTagsChange={setAllTags}
onEditingChange={handleEditingChange}
active={active}
/>
</div>
<CustomizeCredentialsSidebarPanel
open={customizePanelOpen}
onOpenChange={setCustomizePanelOpen}
preferences={sidebarPrefs}
update={updateSidebarPrefs}
/>
</div>
);
}
@@ -0,0 +1,139 @@
import { useTranslation } from "react-i18next";
import { Rows3, SquareStack } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type {
CredentialDensity,
CredentialSidebarPreferences,
CredentialTrayTrigger,
} from "@/types/credential-sidebar-preferences";
export function CustomizeCredentialsSidebarPanel({
open,
onOpenChange,
preferences,
update,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
preferences: CredentialSidebarPreferences;
update: (
patch:
| Partial<CredentialSidebarPreferences>
| ((prev: CredentialSidebarPreferences) => CredentialSidebarPreferences),
) => void;
}) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("credentials.customizeSidebar")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("credentials.customizeSidebarDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<SectionCard
title={t("credentials.customizeDensityTitle")}
icon={<Rows3 className="size-3.5" />}
>
<SettingRow
label={t("credentials.customizeDensityTitle")}
description={
preferences.display.density === "comfortable"
? t("credentials.densityComfortableDesc")
: t("credentials.densityCompactDesc")
}
>
<select
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
density: e.target.value as CredentialDensity,
},
}))
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="comfortable">
{t("credentials.displayDensityComfortable")}
</option>
<option value="compact">
{t("credentials.displayDensityCompact")}
</option>
</select>
</SettingRow>
</SectionCard>
<SectionCard
title={t("credentials.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
>
<SettingRow
label={t("credentials.showCredentialTags")}
description={t("credentials.showCredentialTagsDesc")}
>
<FakeSwitch
checked={preferences.display.showTags}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showTags: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("credentials.actionsVisibility")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("credentials.actionsVisibilityDesc")}
</span>
</div>
<select
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
trayTrigger: e.target.value as CredentialTrayTrigger,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="always">{t("credentials.actionsAlways")}</option>
<option value="actionsOnly">
{t("credentials.actionsOnly")}
</option>
<option value="hover">{t("credentials.actionsHover")}</option>
<option value="click">{t("credentials.actionsClick")}</option>
</select>
</div>
</SectionCard>
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("credentials.customizeSortHint")}
</p>
</div>
</DialogContent>
</Dialog>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { useTranslation } from "react-i18next";
import { Rows3, SquareStack } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type {
HostDensity,
HostSidebarPreferences,
HostTrayTrigger,
} from "@/types/host-sidebar-preferences";
export function CustomizeSidebarPanel({
open,
onOpenChange,
preferences,
update,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
preferences: HostSidebarPreferences;
update: (
patch:
| Partial<HostSidebarPreferences>
| ((prev: HostSidebarPreferences) => HostSidebarPreferences),
) => void;
}) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("hosts.customizeSidebar")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("hosts.customizeSidebarDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<SectionCard
title={t("hosts.customizeDensityTitle")}
icon={<Rows3 className="size-3.5" />}
>
<SettingRow
label={t("hosts.customizeDensityTitle")}
description={
preferences.display.density === "comfortable"
? t("hosts.densityComfortableDesc")
: t("hosts.densityCompactDesc")
}
>
<select
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
density: e.target.value as HostDensity,
},
}))
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="comfortable">
{t("hosts.displayDensityComfortable")}
</option>
<option value="compact">
{t("hosts.displayDensityCompact")}
</option>
</select>
</SettingRow>
</SectionCard>
<SectionCard
title={t("hosts.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
>
<SettingRow
label={t("newUi.sidebar.userProfile.showHostTags")}
description={t("newUi.sidebar.userProfile.showHostTagsDesc")}
>
<FakeSwitch
checked={preferences.display.showTags}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showTags: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.actionsVisibility")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.actionsVisibilityDesc")}
</span>
</div>
<select
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
trayTrigger: e.target.value as HostTrayTrigger,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="always">{t("hosts.actionsAlways")}</option>
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
<option value="hover">{t("hosts.actionsHover")}</option>
<option value="click">{t("hosts.actionsClick")}</option>
</select>
</div>
<SettingRow
label={t("hosts.openOnDoubleClick")}
description={t("hosts.openOnDoubleClickDesc")}
>
<FakeSwitch
checked={preferences.display.openOnDoubleClick}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, openOnDoubleClick: v },
}))
}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.statusColors")}
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
>
<FakeSwitch
checked={preferences.display.statusColorScheme === "status"}
onChange={(v) =>
update((prev) => ({
...prev,
display: {
...prev.display,
statusColorScheme: v ? "status" : "accent",
},
}))
}
/>
</SettingRow>
</SectionCard>
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("hosts.customizeSortHint")}
</p>
</div>
</DialogContent>
</Dialog>
);
}
+419
View File
@@ -0,0 +1,419 @@
import { getErrorMessage } from "../lib/error-message.js";
import { useEffect, useMemo, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Loader2,
RefreshCw,
Search,
Server,
} from "lucide-react";
import { Input } from "@/components/input";
import { Button } from "@/components/button";
import { cn } from "@/lib/utils";
import {
listFleets,
getFleetInventory,
refreshFleetInventory,
type FleetRow,
type FleetInventoryEntry,
} from "@/api/fleets-api";
type SortKey =
| "hostName"
| "osPrettyName"
| "kernel"
| "architecture"
| "uptimeSeconds"
| "packageManager"
| "collectedAt";
type SortDirection = "asc" | "desc";
function formatUptime(seconds: number | null): string {
if (seconds === null) return "-";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function compareValues(
a: string | number | null,
b: string | number | null,
): number {
if (a === null && b === null) return 0;
if (a === null) return 1;
if (b === null) return -1;
if (typeof a === "number" && typeof b === "number") return a - b;
return String(a).localeCompare(String(b));
}
function SortHeader({
label,
column,
sortKey,
sortDirection,
onSort,
}: {
label: string;
column: SortKey;
sortKey: SortKey;
sortDirection: SortDirection;
onSort: (column: SortKey) => void;
}) {
const active = sortKey === column;
return (
<th className="px-3 py-2 text-left font-semibold">
<button
type="button"
onClick={() => onSort(column)}
className="flex items-center gap-1 hover:text-foreground"
>
{label}
{active ? (
sortDirection === "asc" ? (
<ArrowUp className="size-3" />
) : (
<ArrowDown className="size-3" />
)
) : (
<ArrowUpDown className="size-3 opacity-40" />
)}
</button>
</th>
);
}
export function FleetInventoryTab({
fleetId,
isVisible = true,
}: {
fleetId?: number;
isVisible?: boolean;
}) {
const { t } = useTranslation();
const [fleets, setFleets] = useState<FleetRow[]>([]);
const [selectedFleetId, setSelectedFleetId] = useState<number | undefined>(
fleetId,
);
const [entries, setEntries] = useState<FleetInventoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [search, setSearch] = useState("");
const [sortKey, setSortKey] = useState<SortKey>("hostName");
const [sortDirection, setSortDirection] = useState<SortDirection>("asc");
useEffect(() => {
if (fleetId !== undefined) setSelectedFleetId(fleetId);
}, [fleetId]);
useEffect(() => {
listFleets()
.then(setFleets)
.catch(() => {});
}, []);
useEffect(() => {
if (!fleets.length) return;
if (selectedFleetId === undefined) {
setSelectedFleetId(fleets[0].id);
}
}, [fleets, selectedFleetId]);
const loadInventory = useCallback(async () => {
if (selectedFleetId === undefined) return;
setLoading(true);
try {
const data = await getFleetInventory(selectedFleetId);
setEntries(data);
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.fleets.inventoryLoadFailed"));
} finally {
setLoading(false);
}
}, [selectedFleetId, t]);
useEffect(() => {
if (!isVisible) return;
loadInventory();
}, [loadInventory, isVisible]);
async function handleRefresh() {
if (selectedFleetId === undefined) return;
setRefreshing(true);
try {
const { results } = await refreshFleetInventory(selectedFleetId);
const failed = results.filter((r) => !r.success).length;
if (failed > 0) {
toast.warning(
t("newUi.sidebar.fleets.commandPartialFailure", {
failed,
total: results.length,
}),
);
} else {
toast.success(t("newUi.sidebar.fleets.inventoryRefreshed"));
}
await loadInventory();
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.fleets.inventoryRefreshFailed"));
} finally {
setRefreshing(false);
}
}
function handleSort(column: SortKey) {
if (sortKey === column) {
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortKey(column);
setSortDirection("asc");
}
}
const selectedFleet = fleets.find((f) => f.id === selectedFleetId);
const filteredSorted = useMemo(() => {
const q = search.trim().toLowerCase();
const filtered = entries.filter(
(e) =>
!q ||
e.hostName.toLowerCase().includes(q) ||
(e.inventory?.osPrettyName ?? "").toLowerCase().includes(q) ||
(e.inventory?.hostname ?? "").toLowerCase().includes(q),
);
const dir = sortDirection === "asc" ? 1 : -1;
return [...filtered].sort((a, b) => {
let cmp = 0;
switch (sortKey) {
case "hostName":
cmp = compareValues(a.hostName, b.hostName);
break;
case "osPrettyName":
cmp = compareValues(
a.inventory?.osPrettyName ?? null,
b.inventory?.osPrettyName ?? null,
);
break;
case "kernel":
cmp = compareValues(
a.inventory?.kernel ?? null,
b.inventory?.kernel ?? null,
);
break;
case "architecture":
cmp = compareValues(
a.inventory?.architecture ?? null,
b.inventory?.architecture ?? null,
);
break;
case "uptimeSeconds":
cmp = compareValues(
a.inventory?.uptimeSeconds ?? null,
b.inventory?.uptimeSeconds ?? null,
);
break;
case "packageManager":
cmp = compareValues(
a.inventory?.packageManager ?? null,
b.inventory?.packageManager ?? null,
);
break;
case "collectedAt":
cmp = compareValues(
a.inventory?.collectedAt ?? null,
b.inventory?.collectedAt ?? null,
);
break;
}
return cmp * dir;
});
}, [entries, search, sortKey, sortDirection]);
const collectedCount = entries.filter((e) => e.inventory !== null).length;
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2.5">
<Server className="size-4 text-muted-foreground" />
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.fleets.tabInventory")}
</span>
<select
className="px-2 py-1 text-[10px] bg-background border border-border text-foreground outline-none"
value={selectedFleetId ?? ""}
onChange={(e) => setSelectedFleetId(Number(e.target.value))}
>
{fleets.map((fleet) => (
<option key={fleet.id} value={fleet.id}>
{fleet.name}
</option>
))}
</select>
{selectedFleet && (
<span className="text-[11px] text-muted-foreground">
{t("newUi.sidebar.fleets.memberCount", {
count: entries.length,
})}{" "}
&middot;{" "}
{t("newUi.sidebar.fleets.inventoryCollectedCount", {
count: collectedCount,
total: entries.length,
})}
</span>
)}
<div className="ml-auto flex items-center gap-1.5">
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("newUi.sidebar.fleets.searchHosts")}
className="h-7 w-48 pl-6 text-xs"
/>
</div>
<Button
size="sm"
variant="outline"
onClick={handleRefresh}
disabled={refreshing || selectedFleetId === undefined}
>
{refreshing ? (
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
) : (
<RefreshCw className="size-3.5 mr-1.5" />
)}
{t("newUi.sidebar.fleets.refreshInventory")}
</Button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
{loading ? (
<div className="flex h-full items-center justify-center py-16 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : fleets.length === 0 ? (
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
{t("newUi.sidebar.fleets.noFleets")}
</div>
) : filteredSorted.length === 0 ? (
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
{t("newUi.sidebar.fleets.noMembers")}
</div>
) : (
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-card">
<tr className="border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
<SortHeader
label={t("newUi.sidebar.fleets.inventoryHost")}
column="hostName"
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
/>
<SortHeader
label={t("newUi.sidebar.fleets.inventoryOs")}
column="osPrettyName"
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
/>
<SortHeader
label={t("newUi.sidebar.fleets.inventoryKernel")}
column="kernel"
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
/>
<SortHeader
label={t("newUi.sidebar.fleets.inventoryArch")}
column="architecture"
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
/>
<SortHeader
label={t("newUi.sidebar.fleets.inventoryUptime")}
column="uptimeSeconds"
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
/>
<SortHeader
label={t("newUi.sidebar.fleets.inventoryPackageManager")}
column="packageManager"
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
/>
<SortHeader
label={t("newUi.sidebar.fleets.inventoryCollectedAt")}
column="collectedAt"
sortKey={sortKey}
sortDirection={sortDirection}
onSort={handleSort}
/>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredSorted.map((entry) => (
<tr
key={entry.hostId}
className="transition-colors hover:bg-muted/40"
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<span
className={cn(
"size-1.5 shrink-0",
entry.inventory
? "bg-green-500"
: "bg-muted-foreground/50",
)}
/>
<span className="min-w-0 truncate font-semibold">
{entry.hostName}
</span>
</div>
</td>
<td className="px-3 py-2 text-muted-foreground">
{entry.inventory?.osPrettyName ?? "-"}
</td>
<td className="px-3 py-2 font-mono text-muted-foreground">
{entry.inventory?.kernel ?? "-"}
</td>
<td className="px-3 py-2 text-muted-foreground">
{entry.inventory?.architecture ?? "-"}
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{formatUptime(entry.inventory?.uptimeSeconds ?? null)}
</td>
<td className="px-3 py-2 text-muted-foreground">
{entry.inventory?.packageManager ?? "-"}
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{entry.inventory?.collectedAt
? new Date(entry.inventory.collectedAt).toLocaleString()
: "-"}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
+36 -5
View File
@@ -33,6 +33,8 @@ export function FolderMetadataDialog({
open,
mode,
initial,
existingPaths = [],
currentPath,
onOpenChange,
onSubmit,
}: {
@@ -44,6 +46,10 @@ export function FolderMetadataDialog({
icon?: string;
credentialId?: number | null;
};
/** Every folder path that already exists, for duplicate-name validation. */
existingPaths?: string[];
/** The folder's own current full path, in edit mode -- excluded from the duplicate check. */
currentPath?: string;
onOpenChange: (v: boolean) => void;
onSubmit: (value: FolderMetadataValue) => void;
}) {
@@ -54,6 +60,22 @@ export function FolderMetadataDialog({
const [credentialId, setCredentialId] = useState<string>("");
const [credentials, setCredentials] = useState<CredentialOption[]>([]);
const parentPath =
mode === "edit" && currentPath?.includes(" / ")
? currentPath.slice(0, currentPath.lastIndexOf(" / "))
: "";
const resultingPath = name
? mode === "edit"
? parentPath
? `${parentPath} / ${normalizePath(name)}`
: normalizePath(name)
: normalizePath(name)
: "";
const isDuplicate =
!!resultingPath &&
resultingPath !== currentPath &&
existingPaths.some((p) => p.toLowerCase() === resultingPath.toLowerCase());
useEffect(() => {
if (open) {
setName(initial?.name ?? "");
@@ -83,7 +105,7 @@ export function FolderMetadataDialog({
function handleSubmit() {
const normalized = normalizePath(name);
if (!normalized) return;
if (!normalized || isDuplicate) return;
onSubmit({
name: normalized,
color,
@@ -117,12 +139,20 @@ export function FolderMetadataDialog({
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
if (e.key === "Enter" && !isDuplicate) handleSubmit();
}}
aria-invalid={isDuplicate}
className={isDuplicate ? "border-destructive" : undefined}
/>
<p className="text-[10px] text-muted-foreground">
{t("hosts.folderNestingHint")}
</p>
{isDuplicate ? (
<p className="text-[10px] text-destructive">
{t("hosts.folderNameDuplicate")}
</p>
) : (
<p className="text-[10px] text-muted-foreground">
{t("hosts.folderNestingHint")}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
@@ -189,6 +219,7 @@ export function FolderMetadataDialog({
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSubmit}
disabled={!name.trim() || isDuplicate}
>
{mode === "create"
? t("hosts.createFolderButton")
+1 -1
View File
@@ -121,7 +121,7 @@ export function FolderPathPicker({
align="start"
sideOffset={4}
collisionPadding={8}
className="w-max min-w-(--radix-popover-trigger-width) max-w-72 max-h-(--radix-popover-content-available-height) p-0 rounded-none border-0 ring-1 ring-border shadow-md flex flex-col overflow-hidden"
className="w-max min-w-(--radix-popover-trigger-width) max-w-96 max-h-(--radix-popover-content-available-height) p-0 rounded-none border-0 ring-1 ring-border shadow-md flex flex-col overflow-hidden"
>
<div className="flex items-center gap-2 border-b border-border px-2.5 h-8 shrink-0">
<Search className="size-3 shrink-0 text-muted-foreground/60" />
+2
View File
@@ -179,6 +179,8 @@ export function HostAuthOverrideModal({
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
disabled={
loading || loadError || saving || selectedId === initialId
-429
View File
@@ -1,429 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronRight,
Copy,
FolderOpen,
KeyRound,
Loader2,
Pencil,
Plus,
Trash2,
Upload,
X,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { getCredentialDetails } from "@/main-axios";
import { copyToClipboard } from "@/lib/clipboard";
import type { Host, Credential } from "@/types/ui-types";
type CredentialWithCertificate = Credential & { certPublicKey?: string };
type ConfirmDialog = {
message: string;
onConfirm: () => void;
};
function CredentialItem({
cred,
usedByCount,
stripeIndex,
termixIdLinked,
onDeploy,
onEdit,
onDelete,
}: {
cred: Credential;
usedByCount: number;
stripeIndex: number;
termixIdLinked?: boolean;
onDeploy: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const isKey = cred.type === "key";
return (
<div
className={`group relative flex items-stretch cursor-default select-none transition-colors hover:bg-muted/40 ${stripeIndex % 2 === 1 ? "bg-muted/20" : ""}`}
>
{/* Type stripe */}
<div className="w-[3px] shrink-0 bg-transparent" />
<div className="flex flex-col flex-1 min-w-0 px-2.5 pt-2 pb-1.5 gap-1">
{/* Name row */}
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[13px] font-medium truncate text-foreground leading-none">
{cred.name}
</span>
<span
className={`text-[9px] px-1 py-px font-bold border leading-none shrink-0 ${isKey ? "border-accent-brand/30 text-accent-brand" : "border-border/60 text-muted-foreground/60"}`}
>
{isKey ? "KEY" : "PWD"}
</span>
{termixIdLinked && (
<span className="text-[9px] px-1 py-px font-bold border leading-none shrink-0 border-accent-brand/30 text-accent-brand/70">
ID
</span>
)}
</div>
{/* Username row */}
{(cred.username || usedByCount > 0) && (
<span className="text-[11px] text-muted-foreground/45 truncate leading-none pl-3">
{cred.username}
{usedByCount > 0 && (
<span className="text-muted-foreground/30">
{cred.username ? " · " : ""}
{usedByCount}h
</span>
)}
</span>
)}
{/* Tag pills */}
{cred.tags && cred.tags.length > 0 && (
<div className="flex items-center gap-1 min-w-0 overflow-hidden pl-3">
{cred.tags.slice(0, 4).map((tag) => (
<span
key={tag}
className="text-[9px] px-1 py-px border border-border/50 bg-muted/30 text-muted-foreground/60 lowercase shrink-0 leading-none"
>
{tag}
</span>
))}
{cred.tags.length > 4 && (
<span className="text-[9px] text-muted-foreground/40 shrink-0">
+{cred.tags.length - 4}
</span>
)}
</div>
)}
{/* Action tray — slides open on hover */}
<div className="overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 group-hover:max-h-[60px] group-hover:opacity-100">
<div className="flex items-center gap-1 pt-1.5 pl-2 pb-1 border-t border-border/40 mt-0.5">
{isKey && (
<>
<button
title="Deploy key to host"
onClick={onDeploy}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Upload className="size-3.5" />
</button>
<button
title="Copy deploy command"
onClick={async () => {
const pubKey = cred.publicKey;
if (!pubKey) {
toast.error(t("credentials.noPublicKeyAvailable"));
return;
}
const cmd = `mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "${pubKey}" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`;
const ok = await copyToClipboard(cmd);
if (ok) toast.success(t("credentials.deployCommandCopied"));
else toast.error(t("common.copyFailed"));
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Copy className="size-3.5" />
</button>
</>
)}
<button
title="Edit credential"
onClick={onEdit}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Pencil className="size-3.5" />
</button>
<button
title="Delete credential"
onClick={onDelete}
className="flex items-center justify-center size-7 rounded text-muted-foreground/40 hover:text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="size-3.5" />
</button>
</div>
</div>
</div>
</div>
);
}
function CredentialFolderItem({
folder,
creds,
allHosts,
stripeOffset,
editingFolderName,
editingFolderValue,
termixIdLinkedIds,
onEditingFolderNameChange,
onEditingFolderValueChange,
onRenameFolder,
onDeploy,
onEdit,
onDelete,
}: {
folder: string;
creds: Credential[];
allHosts: Host[];
stripeOffset: number;
editingFolderName: string | null;
editingFolderValue: string;
termixIdLinkedIds?: Set<number>;
onEditingFolderNameChange: (name: string | null) => void;
onEditingFolderValueChange: (value: string) => void;
onRenameFolder: (folder: string, newName: string) => Promise<void>;
onDeploy: (cred: Credential) => void;
onEdit: (cred: Credential) => void;
onDelete: (cred: Credential) => void;
}) {
const [open, setOpen] = useState(false);
return (
<div>
<button
onClick={() => setOpen((v) => !v)}
className={`group/folder flex items-center gap-2 w-full px-3 py-2 hover:bg-muted/50 transition-colors text-left cursor-pointer ${stripeOffset % 2 === 1 ? "bg-muted/20" : ""}`}
>
<ChevronRight
className={`size-3 shrink-0 text-muted-foreground/50 transition-transform ${open ? "rotate-90" : ""}`}
/>
<FolderOpen
className={`size-3.5 shrink-0 ${open ? "text-accent-brand" : "text-muted-foreground/60"}`}
/>
{editingFolderName === folder ? (
<>
<input
autoFocus
value={editingFolderValue}
onChange={(e) => onEditingFolderValueChange(e.target.value)}
onBlur={async () => {
const newName = editingFolderValue.trim();
onEditingFolderNameChange(null);
if (newName && newName !== folder) {
await onRenameFolder(folder, newName);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") onEditingFolderNameChange(null);
}}
onClick={(e) => e.stopPropagation()}
className="text-[10px] font-semibold bg-background border border-accent-brand/60 px-1 outline-none text-foreground min-w-0 flex-1"
/>
<button
onClick={(e) => {
e.stopPropagation();
onEditingFolderNameChange(null);
}}
className="text-muted-foreground hover:text-foreground shrink-0"
>
<X className="size-3" />
</button>
</>
) : (
<>
<span className="text-[13px] font-semibold text-foreground/80 truncate flex-1">
{folder}
</span>
<span className="text-[10px] tabular-nums shrink-0 ml-1 text-muted-foreground/40">
{creds.length}
</span>
{folder !== "Uncategorized" && (
<span
className="opacity-0 group-hover/folder:opacity-100 transition-opacity ml-1 text-muted-foreground/50 hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
onEditingFolderNameChange(folder);
onEditingFolderValueChange(folder);
}}
>
<Pencil className="size-2.5" />
</span>
)}
</>
)}
</button>
{open && (
<div className="border-l border-border/40 ml-[30px]">
{creds.map((cred, i) => {
const usedByCount = allHosts.filter(
(h) => h.credentialId === cred.id,
).length;
return (
<CredentialItem
key={cred.id}
cred={cred}
usedByCount={usedByCount}
stripeIndex={stripeOffset + 1 + i}
termixIdLinked={termixIdLinkedIds?.has(Number(cred.id))}
onDeploy={() => onDeploy(cred)}
onEdit={() => onEdit(cred)}
onDelete={() => onDelete(cred)}
/>
);
})}
</div>
)}
</div>
);
}
export function HostCredentialList({
credentialFolders,
filteredCredentials,
credentialsLoading,
allHosts,
editingFolderName,
editingFolderValue,
termixIdLinkedIds,
onEditingFolderNameChange,
onEditingFolderValueChange,
onRenameFolder,
onDeployCredential,
onEditCredential,
onDeleteCredential,
onAddCredential,
onConfirmDialogChange,
}: {
credentialFolders: string[];
filteredCredentials: Credential[];
credentialsLoading: boolean;
allHosts: Host[];
editingFolderName: string | null;
editingFolderValue: string;
termixIdLinkedIds?: Set<number>;
onEditingFolderNameChange: (name: string | null) => void;
onEditingFolderValueChange: (value: string) => void;
onRenameFolder: (folder: string, newName: string) => Promise<void>;
onDeployCredential: (cred: Credential) => void;
onEditCredential: (cred: Credential) => void;
onDeleteCredential: (cred: Credential) => Promise<void>;
onAddCredential: () => void;
onConfirmDialogChange: (dialog: ConfirmDialog) => void;
}) {
const { t } = useTranslation();
async function handleDelete(cred: Credential) {
onConfirmDialogChange({
message: t("hosts.deleteCredentialConfirm", { name: cred.name }),
onConfirm: async () => {
try {
await onDeleteCredential(cred);
toast.success(t("hosts.deletedCredential", { name: cred.name }));
} catch {
toast.error(t("hosts.failedToDeleteCredential2"));
}
},
});
}
async function handleEdit(cred: Credential) {
try {
const full = await getCredentialDetails(Number(cred.id));
onEditCredential({
...cred,
value: (
full as CredentialWithCertificate & {
hasKey?: boolean;
hasKeyPassword?: boolean;
}
).hasKey
? "existing_key"
: ((
full as CredentialWithCertificate & {
password?: string;
}
).password ?? ""),
password:
(full as CredentialWithCertificate & { password?: string })
.password ?? "",
passphrase: (
full as CredentialWithCertificate & {
hasKeyPassword?: boolean;
}
).hasKeyPassword
? "existing_key_password"
: "",
publicKey:
(full as CredentialWithCertificate).certPublicKey ?? cred.publicKey,
});
} catch {
onEditCredential(cred);
}
}
let globalStripe = 0;
return (
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col">
{credentialFolders.map((folder) => {
const creds = filteredCredentials.filter(
(c) => (c.folder || "Uncategorized") === folder,
);
if (creds.length === 0) return null;
const offset = globalStripe;
globalStripe += 1 + creds.length;
return (
<CredentialFolderItem
key={folder}
folder={folder}
creds={creds}
allHosts={allHosts}
stripeOffset={offset}
editingFolderName={editingFolderName}
editingFolderValue={editingFolderValue}
termixIdLinkedIds={termixIdLinkedIds}
onEditingFolderNameChange={onEditingFolderNameChange}
onEditingFolderValueChange={onEditingFolderValueChange}
onRenameFolder={onRenameFolder}
onDeploy={onDeployCredential}
onEdit={handleEdit}
onDelete={handleDelete}
/>
);
})}
{credentialsLoading && (
<div className="flex flex-col px-2 py-2 space-y-1.5">
{[60, 45, 55, 40].map((w, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2">
<div className="size-3 rounded-sm bg-muted/50 animate-pulse shrink-0" />
<div
className="h-3 rounded bg-muted/50 animate-pulse"
style={{ width: `${w * 2}px` }}
/>
</div>
))}
<div className="flex items-center justify-center gap-2 pt-2 text-muted-foreground/40">
<Loader2 className="size-3.5 animate-spin" />
<span className="text-xs">{t("hosts.loadingCredentials")}</span>
</div>
</div>
)}
{!credentialsLoading && filteredCredentials.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
<KeyRound className="size-8 text-muted-foreground/20 mb-2" />
<span className="text-sm font-semibold text-muted-foreground/60">
{t("hosts.noCredentialsFound")}
</span>
<Button
variant="outline"
size="sm"
className="mt-3 h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
onClick={onAddCredential}
>
<Plus className="size-3 mr-1" />
{t("hosts.addCredentialBtn2")}
</Button>
</div>
)}
</div>
</div>
);
}
+489 -218
View File
@@ -11,16 +11,25 @@ import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { Slider } from "@/components/slider";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip";
import {
TERMINAL_FONT_ZOOM_MIN,
TERMINAL_FONT_ZOOM_MAX,
} from "@/features/terminal/terminal-font-zoom";
import {
Globe,
Info,
Layers, // --- tmux-monitor ---
LayoutGrid,
Network,
Palette,
Plus,
Settings,
Shield,
Upload,
X,
@@ -61,6 +70,7 @@ import {
createHostEditorForm,
mapSnippetResponse,
omitOwnerSshAuthFromSharedEdit,
terminalAppearanceKeys,
type HostAuthType,
type HostBellStyle,
type HostBackspaceMode,
@@ -68,6 +78,7 @@ import {
type HostFastScrollModifier,
type HostProtocols,
} from "./HostEditorData";
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
import {
HostDockerTab,
HostProxmoxTab,
@@ -83,6 +94,12 @@ import {
import { HostStatsTab } from "./HostEditorStatsTab";
import { VaultProfileManager } from "./VaultProfileManager";
import { findHostByTunnelEndpoint } from "@/features/tunnel/tunnel-endpoints";
import {
toCredentialOption,
type CredentialOption,
} from "./quick-created-credential";
const CUSTOM_FONT_OPTION = "__custom__";
export function HostEditor({
host,
@@ -91,17 +108,22 @@ export function HostEditor({
onSave,
protocols,
onProtocolChange,
onDirtyChange,
onTabChange,
hosts,
credentials,
adminTargetUserId,
simpleMode = false,
}: {
host: Host | null;
activeTab: string;
/** Collapses the General tab to the fields needed to reach a host. */
simpleMode?: boolean;
onBack: () => void;
onSave: (saved: SSHHost) => void;
protocols: HostProtocols;
onProtocolChange: (p: Partial<typeof protocols>) => void;
onDirtyChange?: (dirty: boolean) => void;
onTabChange: (tab: string) => void;
hosts: Host[];
credentials: { id: string; name: string; username: string }[];
@@ -111,13 +133,35 @@ export function HostEditor({
}) {
const { t } = useTranslation();
const { setPreviewTerminalTheme } = useTabsSafe();
const [form, setForm] = useState(() => createHostEditorForm(host));
const connectionDefaults = useConnectionDefaults();
const [form, setForm] = useState(() =>
createHostEditorForm(host, undefined, connectionDefaults),
);
const [isCustomFont, setIsCustomFont] = useState(
() => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily),
);
const setField = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) =>
setForm((p) => ({ ...p, [k]: v }));
const setField = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) => {
onDirtyChange?.(true);
setForm((p) => ({
...p,
[k]: v,
...(terminalAppearanceKeys.includes(
k as (typeof terminalAppearanceKeys)[number],
)
? { inheritTerminalAppearance: false }
: {}),
}));
};
const setGuacField = (key: string, value: unknown) =>
setField("guacamoleConfig", { ...form.guacamoleConfig, [key]: value });
const setGuacField = (key: string, value: unknown) => {
onDirtyChange?.(true);
setForm((current) => ({
...current,
inheritRemoteDesktopDefaults: false,
guacamoleConfig: { ...current.guacamoleConfig, [key]: value },
}));
};
const [saving, setSaving] = useState(false);
const [snippets, setSnippets] = useState<{ id: number; name: string }[]>([]);
@@ -136,6 +180,7 @@ export function HostEditor({
>([]);
const [tailscaleHasApiKey, setTailscaleHasApiKey] = useState(false);
const [tailscaleLoading, setTailscaleLoading] = useState(false);
const [tailscaleDeviceError, setTailscaleDeviceError] = useState(false);
const [connectingTunnel, setConnectingTunnel] = useState<number | null>(null);
const [isOidcUser, setIsOidcUser] = useState(false);
const [vaultProfiles, setVaultProfiles] = useState<VaultProfile[]>([]);
@@ -144,6 +189,8 @@ export function HostEditor({
const [creatingQuickCredential, setCreatingQuickCredential] = useState(false);
const [showQuickCredentialDialog, setShowQuickCredentialDialog] =
useState(false);
const [quickCreatedCredential, setQuickCreatedCredential] =
useState<CredentialOption | null>(null);
const [savedThemes, setSavedThemes] = useState<SavedCustomTheme[]>([]);
const [savingTheme, setSavingTheme] = useState(false);
@@ -223,11 +270,15 @@ export function HostEditor({
}, [adminTargetUserId]);
useEffect(() => {
if (host) return;
if (!connectionDefaults.ready) return;
if (host) {
setForm(createHostEditorForm(host, undefined, connectionDefaults));
return;
}
getHostDefaults()
.then((d) => setForm(createHostEditorForm(null, d)))
.then((d) => setForm(createHostEditorForm(null, d, connectionDefaults)))
.catch(() => {});
}, [host]);
}, [host, connectionDefaults]);
useEffect(() => {
if (!host?.id || form.vncAuthType !== "direct" || form.vncPassword) return;
@@ -257,12 +308,14 @@ export function HostEditor({
useEffect(() => {
if (form.authType !== "tailscale") return;
setTailscaleLoading(true);
setTailscaleDeviceError(false);
getTailscaleDevices()
.then((res) => {
setTailscaleDevices(res?.devices ?? []);
setTailscaleHasApiKey(res?.hasApiKey ?? false);
setTailscaleDeviceError(!!res?.error);
})
.catch(() => {})
.catch(() => setTailscaleDeviceError(true))
.finally(() => setTailscaleLoading(false));
}, [form.authType]);
@@ -294,7 +347,14 @@ export function HostEditor({
};
const authMethod = form.authType;
const selectedCredential = credentials.find(
const availableCredentials =
quickCreatedCredential &&
!credentials.some(
(credential) => credential.id === quickCreatedCredential.id,
)
? [...credentials, quickCreatedCredential]
: credentials;
const selectedCredential = availableCredentials.find(
(c) => c.id === form.credentialId,
);
@@ -330,8 +390,11 @@ export function HostEditor({
if (authMethod === "password") {
data.authType = "password";
data.password =
form.password ||
(host?.hasPassword ? await fetchField("password") : null);
form.password && form.password !== "existing_password"
? form.password
: host?.hasPassword
? await fetchField("password")
: null;
} else {
const key =
form.key && form.key !== "existing_key"
@@ -348,14 +411,24 @@ export function HostEditor({
data.authType = "key";
data.key = key;
data.keyPassword = keyPassword;
data.password = form.password || null;
data.password =
form.password && form.password !== "existing_password"
? form.password
: null;
}
if (adminTargetUserId) {
await adminCreateUserCredential(adminTargetUserId, data);
} else {
await createCredential(data);
}
const created = adminTargetUserId
? await adminCreateUserCredential(adminTargetUserId, data)
: await createCredential(data);
const credential = toCredentialOption(created);
if (!credential) throw new Error(t("hosts.failedToSaveCredential"));
setQuickCreatedCredential(credential);
setForm((current) => ({
...current,
authType: "credential",
credentialId: credential.id,
}));
toast.success(t("hosts.credentialCreated"));
if (!adminTargetUserId) {
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
@@ -380,6 +453,7 @@ export function HostEditor({
proto: keyof typeof protocols,
value: boolean,
) => {
onDirtyChange?.(true);
onProtocolChange({ [proto]: value });
const tabForProto: Record<string, string> = {
enableSsh: "ssh",
@@ -434,6 +508,7 @@ export function HostEditor({
handleProtocolToggle={handleProtocolToggle}
hosts={hosts}
host={host}
simpleMode={simpleMode}
/>
)}
@@ -493,9 +568,16 @@ export function HostEditor({
>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.authMethod")}
{t("hosts.authenticationMethod")}
</label>
<div className="flex flex-wrap gap-2">
<p className="text-[10px] text-muted-foreground">
{t("hosts.authenticationMethodDesc")}
</p>
<div
className="flex flex-wrap gap-2"
role="radiogroup"
aria-label={t("hosts.authenticationMethod")}
>
{[
"password",
"key",
@@ -508,6 +590,9 @@ export function HostEditor({
].map((m) => (
<button
key={m}
type="button"
role="radio"
aria-checked={authMethod === m}
disabled={lockAuthReferences}
title={
lockAuthReferences
@@ -570,8 +655,20 @@ export function HostEditor({
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.password}
placeholder={
form.password === "existing_password"
? t("hosts.passwordSaved")
: "••••••••"
}
value={
form.password === "existing_password"
? ""
: form.password
}
onFocus={() => {
if (form.password === "existing_password")
setField("password", "");
}}
onChange={(e) => setField("password", e.target.value)}
/>
</div>
@@ -689,8 +786,20 @@ export function HostEditor({
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.password}
placeholder={
form.password === "existing_password"
? t("hosts.passwordSaved")
: "••••••••"
}
value={
form.password === "existing_password"
? ""
: form.password
}
onFocus={() => {
if (form.password === "existing_password")
setField("password", "");
}}
onChange={(e) =>
setField("password", e.target.value)
}
@@ -802,7 +911,7 @@ export function HostEditor({
const newId = e.target.value;
setField("credentialId", newId);
if (!form.overrideCredentialUsername) {
const cred = credentials.find(
const cred = availableCredentials.find(
(c) => c.id === newId,
);
if (cred?.username)
@@ -814,7 +923,7 @@ export function HostEditor({
<option value="">
{t("hosts.selectACredential")}
</option>
{credentials.map((c) => (
{availableCredentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username
? `${c.name} (${c.username})`
@@ -853,8 +962,20 @@ export function HostEditor({
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.password}
placeholder={
form.password === "existing_password"
? t("hosts.passwordSaved")
: "••••••••"
}
value={
form.password === "existing_password"
? ""
: form.password
}
onFocus={() => {
if (form.password === "existing_password")
setField("password", "");
}}
onChange={(e) =>
setField("password", e.target.value)
}
@@ -898,14 +1019,18 @@ export function HostEditor({
{t("hosts.tailscaleDocsLink")}
</a>
</div>
{!tailscaleHasApiKey && !tailscaleLoading ? (
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleNoApiKey")}
</p>
) : tailscaleLoading ? (
{tailscaleLoading ? (
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleLoadingDevices")}
</p>
) : tailscaleDeviceError ? (
<p className="text-[10px] text-destructive">
{t("hosts.tailscaleDeviceLoadFailed")}
</p>
) : !tailscaleHasApiKey ? (
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleNoApiKey")}
</p>
) : tailscaleDevices.length === 0 ? (
<p className="text-[10px] text-muted-foreground">
{t("hosts.tailscaleNoDevices")}
@@ -956,25 +1081,13 @@ export function HostEditor({
)}
</div>
)}
{authMethod === "warpgate" && (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.warpgateLabel")}
</span>
</div>
<p className="text-[10px] text-muted-foreground">
{t("hosts.warpgateDesc")}
</p>
</div>
)}
{authMethod === "agent" && (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.agentLabel")}
</span>
<p className="text-[10px] text-muted-foreground">
{t("hosts.agentDesc")}
{t("hosts.agentAuthenticationDesc")}
</p>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
@@ -1041,8 +1154,20 @@ export function HostEditor({
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.sudoPassword}
placeholder={
form.sudoPassword === "existing_sudo_password"
? t("hosts.sudoPasswordSaved")
: "••••••••"
}
value={
form.sudoPassword === "existing_sudo_password"
? ""
: form.sudoPassword
}
onFocus={() => {
if (form.sudoPassword === "existing_sudo_password")
setField("sudoPassword", "");
}}
onChange={(e) =>
setField("sudoPassword", e.target.value)
}
@@ -1061,180 +1186,243 @@ export function HostEditor({
icon={<Palette className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.themePreview")}
</label>
<TerminalPreview
theme={form.theme}
fontSize={form.fontSize}
fontFamily={form.fontFamily}
cursorStyle={form.cursorStyle}
cursorBlink={form.cursorBlink}
letterSpacing={form.letterSpacing}
lineHeight={form.lineHeight}
customThemeColors={form.customThemeColors ?? undefined}
<SettingRow
label={t("hosts.useUserDefaults", {
defaultValue: "Use user defaults",
})}
description={t("hosts.useUserTerminalDefaultsDesc", {
defaultValue:
"Keep this host synchronized with Terminal defaults from User Profile.",
})}
>
<FakeSwitch
checked={form.inheritTerminalAppearance}
onChange={(value) =>
setField("inheritTerminalAppearance", value)
}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
</SettingRow>
<fieldset
disabled={form.inheritTerminalAppearance}
className={
form.inheritTerminalAppearance
? "contents opacity-60"
: "contents"
}
>
<div className="space-y-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.colorTheme")}
{t("hosts.themePreview")}
</label>
<select
value={form.theme}
onChange={(e) => {
const newTheme = e.target.value;
setField("theme", newTheme);
setPreviewTerminalTheme(newTheme);
if (
newTheme === "custom" &&
!form.customThemeColors
) {
setField("customThemeColors", {
...TERMINAL_THEMES.termixDark.colors,
});
<TerminalPreview
theme={form.theme}
fontSize={form.fontSize}
fontFamily={form.fontFamily}
cursorStyle={form.cursorStyle}
cursorBlink={form.cursorBlink}
letterSpacing={form.letterSpacing}
lineHeight={form.lineHeight}
customThemeColors={form.customThemeColors ?? undefined}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.colorTheme")}
</label>
<select
value={form.theme}
onChange={(e) => {
const newTheme = e.target.value;
setField("theme", newTheme);
setPreviewTerminalTheme(newTheme);
if (
newTheme === "custom" &&
!form.customThemeColors
) {
setField("customThemeColors", {
...TERMINAL_THEMES.termixDark.colors,
});
}
}}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{Object.entries(TERMINAL_THEMES)
.filter(
([key]) =>
key !== "termixDark" && key !== "termixLight",
)
.map(([key, theme]) => (
<option key={key} value={key}>
{theme.name}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.fontFamilyLabel")}
</label>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Info className="size-3 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent side="top">
{t("hosts.fontFamilyCustomHint")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<select
value={
isCustomFont ? CUSTOM_FONT_OPTION : form.fontFamily
}
}}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{Object.entries(TERMINAL_THEMES)
.filter(
([key]) =>
key !== "termixDark" && key !== "termixLight",
)
.map(([key, theme]) => (
<option key={key} value={key}>
{theme.name}
onChange={(e) => {
if (e.target.value === CUSTOM_FONT_OPTION) {
setIsCustomFont(true);
setField("fontFamily", "");
} else {
setIsCustomFont(false);
setField("fontFamily", e.target.value);
}
}}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono"
>
{TERMINAL_FONTS.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.fontFamilyLabel")}
</label>
<select
value={form.fontFamily}
onChange={(e) => setField("fontFamily", e.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono"
>
{TERMINAL_FONTS.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
<option value={CUSTOM_FONT_OPTION}>
{t("hosts.fontFamilyCustomOption")}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.fontSizeLabel")}
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{form.fontSize}px
</span>
</select>
{isCustomFont && (
<Input
value={form.fontFamily}
onChange={(e) =>
setField("fontFamily", e.target.value)
}
placeholder={t("hosts.fontFamilyCustomPlaceholder")}
className="h-9 text-xs font-mono"
/>
)}
</div>
<Slider
min={TERMINAL_FONT_ZOOM_MIN}
max={TERMINAL_FONT_ZOOM_MAX}
step={1}
value={[form.fontSize]}
onValueChange={([v]) => setField("fontSize", v)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.cursorStyleLabel")}
</label>
<select
value={form.cursorStyle}
onChange={(e) =>
setField(
"cursorStyle",
e.target.value as HostCursorStyle,
)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{CURSOR_STYLES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.letterSpacingPx")}
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{form.letterSpacing}px
</span>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.fontSizeLabel")}
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{form.fontSize}px
</span>
</div>
<Slider
min={TERMINAL_FONT_ZOOM_MIN}
max={TERMINAL_FONT_ZOOM_MAX}
step={1}
value={[form.fontSize]}
onValueChange={([v]) => setField("fontSize", v)}
/>
</div>
<Slider
min={-2}
max={10}
step={0.5}
value={[form.letterSpacing]}
onValueChange={([v]) => setField("letterSpacing", v)}
/>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.lineHeightLabel")}
{t("hosts.cursorStyleLabel")}
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{form.lineHeight.toFixed(1)}
</span>
<select
value={form.cursorStyle}
onChange={(e) =>
setField(
"cursorStyle",
e.target.value as HostCursorStyle,
)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{CURSOR_STYLES.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.letterSpacingPx")}
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{form.letterSpacing}px
</span>
</div>
<Slider
min={-2}
max={10}
step={0.5}
value={[form.letterSpacing]}
onValueChange={([v]) => setField("letterSpacing", v)}
/>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.lineHeightLabel")}
</label>
<span className="text-[10px] text-muted-foreground tabular-nums">
{form.lineHeight.toFixed(1)}
</span>
</div>
<Slider
min={1.0}
max={2.0}
step={0.1}
value={[form.lineHeight]}
onValueChange={([v]) => setField("lineHeight", v)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.bellStyleLabel")}
</label>
<select
value={form.bellStyle}
onChange={(e) =>
setField(
"bellStyle",
e.target.value as HostBellStyle,
)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{BELL_STYLES.map((b) => (
<option key={b.value} value={b.value}>
{b.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.backspaceModeLabel")}
</label>
<select
value={form.backspaceMode}
onChange={(e) =>
setField(
"backspaceMode",
e.target.value as HostBackspaceMode,
)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="normal">Normal (DEL)</option>
<option value="control-h">Control-H (BS)</option>
</select>
</div>
<Slider
min={1.0}
max={2.0}
step={0.1}
value={[form.lineHeight]}
onValueChange={([v]) => setField("lineHeight", v)}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.bellStyleLabel")}
</label>
<select
value={form.bellStyle}
onChange={(e) =>
setField("bellStyle", e.target.value as HostBellStyle)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{BELL_STYLES.map((b) => (
<option key={b.value} value={b.value}>
{b.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.backspaceModeLabel")}
</label>
<select
value={form.backspaceMode}
onChange={(e) =>
setField(
"backspaceMode",
e.target.value as HostBackspaceMode,
)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="normal">Normal (DEL)</option>
<option value="control-h">Control-H (BS)</option>
</select>
</div>
</div>
</fieldset>
{form.theme === "custom" && (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
@@ -1426,6 +1614,15 @@ export function HostEditor({
onChange={(v) => setField("rightClickSelectsWord", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.macOptionIsMetaLabel")}
description={t("hosts.macOptionIsMetaShortDesc")}
>
<FakeSwitch
checked={form.macOptionIsMeta}
onChange={(v) => setField("macOptionIsMeta", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.syntaxHighlightingLabel")}
description={t("hosts.syntaxHighlightingDesc")}
@@ -1654,6 +1851,39 @@ export function HostEditor({
onChange={(v) => setField("enableCommandHistory", v)}
/>
</SettingRow>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.localEchoLabel")}
</label>
<select
value={form.localEcho}
onChange={(e) =>
setField(
"localEcho",
e.target.value as "default" | "off" | "auto" | "on",
)
}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="default">
{t("hosts.localEchoDefault")}
</option>
<option value="off">{t("hosts.localEchoOff")}</option>
<option value="auto">{t("hosts.localEchoAuto")}</option>
<option value="on">{t("hosts.localEchoOn")}</option>
</select>
<p className="text-[10px] text-muted-foreground">
{t("hosts.localEchoDesc")}{" "}
<a
href="https://docs.termix.site/features/terminal/appearance"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</p>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.linkClickBehaviorLabel")}
@@ -1892,6 +2122,22 @@ export function HostEditor({
</SettingRow>
</div>
</SectionCard>
<SectionCard
title={t("terminalToolbar.title")}
icon={<LayoutGrid className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-3">
<SettingRow
label={t("hosts.enableTerminalToolbar")}
description={t("hosts.enableTerminalToolbarDesc")}
>
<FakeSwitch
checked={form.enableTerminalToolbar}
onChange={(v) => setField("enableTerminalToolbar", v)}
/>
</SettingRow>
</div>
</SectionCard>
</>
)}
@@ -2298,13 +2544,38 @@ export function HostEditor({
)}
{activeTab === "rdp" && (
<HostEditorRdpTab
form={form}
setField={setField}
setGuacField={setGuacField}
host={host}
credentials={credentials}
/>
<>
<SectionCard
title={t("hosts.rdpDefaults", {
defaultValue: "RDP defaults",
})}
icon={<Settings className="size-3.5" />}
>
<SettingRow
label={t("hosts.useUserDefaults", {
defaultValue: "Use user defaults",
})}
description={t("hosts.useUserRdpDefaultsDesc", {
defaultValue:
"Inherit performance, redirection, and clipboard settings from User Profile.",
})}
>
<FakeSwitch
checked={form.inheritRemoteDesktopDefaults}
onChange={(value) =>
setField("inheritRemoteDesktopDefaults", value)
}
/>
</SettingRow>
</SectionCard>
<HostEditorRdpTab
form={form}
setField={setField}
setGuacField={setGuacField}
host={host}
credentials={availableCredentials}
/>
</>
)}
{activeTab === "vnc" && (
@@ -2313,7 +2584,7 @@ export function HostEditor({
setField={setField}
setGuacField={setGuacField}
host={host}
credentials={credentials}
credentials={availableCredentials}
/>
)}
@@ -2323,7 +2594,7 @@ export function HostEditor({
setField={setField}
setGuacField={setGuacField}
host={host}
credentials={credentials}
credentials={availableCredentials}
/>
)}
</div>
+171 -66
View File
@@ -2,6 +2,10 @@ import { TERMINAL_THEMES } from "@/lib/terminal-themes";
import type { Host } from "@/types/ui-types";
import type { SSHHostData } from "@/types";
import type { HostDefaults } from "@/api/settings-api";
import type {
RemoteDesktopDefaults,
TerminalDefaults,
} from "@/lib/connection-defaults";
type HostSocks5ProxyNode = NonNullable<Host["socks5ProxyChain"]>[number];
@@ -24,6 +28,54 @@ export type HostFastScrollModifier = NonNullable<
Host["terminalConfig"]
>["fastScrollModifier"];
export const terminalAppearanceKeys = [
"theme",
"cursorBlink",
"cursorStyle",
"fontSize",
"fontFamily",
"scrollback",
"letterSpacing",
"lineHeight",
"bellStyle",
"minimumContrastRatio",
"backgroundImage",
"backgroundImageOpacity",
"customThemeColors",
] as const satisfies readonly (keyof TerminalDefaults)[];
export const remoteDesktopDefaultKeys = [
"colorDepth",
"resizeMethod",
"forceLossless",
"disableAudio",
"enableWallpaper",
"enableFontSmoothing",
"enableDesktopComposition",
"enablePrinting",
"enableDrive",
"disableCopy",
"disablePaste",
] as const satisfies readonly (keyof RemoteDesktopDefaults)[];
export interface UserConnectionDefaults {
terminal: TerminalDefaults;
rdp: RemoteDesktopDefaults;
}
function hasOwn(value: object | undefined, key: PropertyKey): boolean {
return !!value && Object.prototype.hasOwnProperty.call(value, key);
}
function stripKeys<T extends Record<string, unknown>>(
value: T,
keys: readonly string[],
): Partial<T> {
const result = { ...value };
for (const key of keys) delete result[key];
return result;
}
type SnippetListItem = {
id: number;
name?: string;
@@ -46,9 +98,19 @@ export function mapSnippetResponse(
export function createHostEditorForm(
host: Host | null,
defaults?: HostDefaults,
connectionDefaults?: UserConnectionDefaults,
) {
const d = host ? undefined : defaults;
const rawTheme = host?.terminalConfig?.theme ?? d?.theme;
const terminalConfig = {
...(connectionDefaults?.terminal ?? {}),
...(host?.terminalConfig ?? {}),
};
const remoteDefaults = host?.enableRdp ? connectionDefaults?.rdp : undefined;
const guacamoleConfig = {
...(remoteDefaults ?? {}),
...(host?.guacamoleConfig ?? {}),
};
const rawTheme = terminalConfig.theme ?? d?.theme;
const normalizedTheme =
!rawTheme ||
["Termix Dark", "Termix Light", "termixDark", "termixLight"].includes(
@@ -70,7 +132,7 @@ export function createHostEditorForm(
authType: host?.authType ?? "password",
useWarpgate: host?.useWarpgate ?? false,
shareSshAuth: host?.shareSshAuth ?? false,
password: host?.password ?? "",
password: host?.hasPassword ? "existing_password" : (host?.password ?? ""),
key: host?.key ?? (host?.hasKey ? "existing_key" : ""),
keyPassword: host?.hasKeyPassword
? "existing_key_password"
@@ -83,6 +145,7 @@ export function createHostEditorForm(
vaultProfileId: host?.vaultProfileId ?? "",
overrideCredentialUsername: host?.overrideCredentialUsername ?? false,
folder: host?.folder ?? "",
parentHostId: host?.parentHostId ?? "",
tags: host?.tags ?? ([] as string[]),
tagInput: "",
notes: host?.notes ?? "",
@@ -110,6 +173,7 @@ export function createHostEditorForm(
enableDocker: host?.enableDocker ?? false,
dockerConfig: host?.dockerConfig ?? { runtime: "docker" as const },
enableTmuxMonitor: host?.enableTmuxMonitor ?? false,
enableTerminalToolbar: host?.enableTerminalToolbar ?? true,
allowSessionSharing: host?.allowSessionSharing ?? true,
enableProxmox: host?.enableProxmox ?? false,
proxmoxConfig: host?.proxmoxConfig ?? {
@@ -122,29 +186,43 @@ export function createHostEditorForm(
syncIntervalMinutes: 15,
markMissingGuests: true,
},
enableProxmoxStats: host?.enableProxmoxStats ?? false,
proxmoxStatsConfig: host?.proxmoxStatsConfig ?? {
pollInterval: 60,
nodeName: null as string | null,
},
enableTunnel: host?.enableTunnel ?? false,
defaultPath: host?.defaultPath ?? "/",
forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false,
fontSize: host?.terminalConfig?.fontSize ?? d?.fontSize ?? 14,
inheritTerminalAppearance:
!host ||
terminalAppearanceKeys.every((key) => !hasOwn(host.terminalConfig, key)),
inheritRemoteDesktopDefaults:
!host ||
remoteDesktopDefaultKeys.every(
(key) => !hasOwn(host.guacamoleConfig, key),
),
localEcho: host?.terminalConfig?.localEcho ?? "default",
fontSize: terminalConfig.fontSize ?? d?.fontSize ?? 14,
fontFamily:
host?.terminalConfig?.fontFamily ??
terminalConfig.fontFamily ??
d?.fontFamily ??
"Caskaydia Cove Nerd Font Mono",
theme: normalizedTheme,
cursorStyle: (host?.terminalConfig?.cursorStyle ??
d?.cursorStyle ??
"bar") as "block" | "underline" | "bar",
cursorBlink: host?.terminalConfig?.cursorBlink ?? d?.cursorBlink ?? true,
scrollback: host?.terminalConfig?.scrollback ?? 10000,
letterSpacing: host?.terminalConfig?.letterSpacing ?? 0,
lineHeight: host?.terminalConfig?.lineHeight ?? 1.0,
bellStyle: (host?.terminalConfig?.bellStyle ?? "none") as
cursorStyle: (terminalConfig.cursorStyle ?? d?.cursorStyle ?? "bar") as
"block" | "underline" | "bar",
cursorBlink: terminalConfig.cursorBlink ?? d?.cursorBlink ?? true,
scrollback: terminalConfig.scrollback ?? 10000,
letterSpacing: terminalConfig.letterSpacing ?? 0,
lineHeight: terminalConfig.lineHeight ?? 1.0,
bellStyle: (terminalConfig.bellStyle ?? "none") as
"none" | "sound" | "visual" | "both",
rightClickSelectsWord: host?.terminalConfig?.rightClickSelectsWord ?? false,
macOptionIsMeta: host?.terminalConfig?.macOptionIsMeta ?? true,
fastScrollModifier: (host?.terminalConfig?.fastScrollModifier ?? "alt") as
"alt" | "ctrl" | "shift",
fastScrollSensitivity: host?.terminalConfig?.fastScrollSensitivity ?? 5,
minimumContrastRatio: host?.terminalConfig?.minimumContrastRatio ?? 1,
minimumContrastRatio: terminalConfig.minimumContrastRatio ?? 1,
backspaceMode: (host?.terminalConfig?.backspaceMode ?? "normal") as
"normal" | "control-h",
startupSnippetId: host?.terminalConfig?.startupSnippetId ?? null,
@@ -153,13 +231,14 @@ export function createHostEditorForm(
autoMosh: host?.terminalConfig?.autoMosh ?? false,
autoTmux: host?.terminalConfig?.autoTmux ?? false,
sudoPasswordAutoFill: host?.terminalConfig?.sudoPasswordAutoFill ?? false,
sudoPassword: host?.terminalConfig?.sudoPassword ?? "",
sudoPassword: host?.hasSudoPassword
? "existing_sudo_password"
: (host?.terminalConfig?.sudoPassword ?? ""),
keepaliveInterval: host?.terminalConfig?.keepaliveInterval ?? 60,
keepaliveCountMax: host?.terminalConfig?.keepaliveCountMax ?? 5,
backgroundImage: host?.terminalConfig?.backgroundImage ?? "",
backgroundImageOpacity:
host?.terminalConfig?.backgroundImageOpacity ?? 0.15,
customThemeColors: host?.terminalConfig?.customThemeColors ?? null,
backgroundImage: terminalConfig.backgroundImage ?? "",
backgroundImageOpacity: terminalConfig.backgroundImageOpacity ?? 0.15,
customThemeColors: terminalConfig.customThemeColors ?? null,
allowLegacyAlgorithms: host?.terminalConfig?.allowLegacyAlgorithms ?? true,
linkClickBehavior: (host?.terminalConfig?.linkClickBehavior ??
"default") as "default" | "confirm" | "direct",
@@ -215,7 +294,7 @@ export function createHostEditorForm(
telnetAuthType: (host?.telnetAuthType ??
(host?.telnetCredentialId ? "credential" : "direct")) as
"direct" | "credential",
guacamoleConfig: host?.guacamoleConfig ?? {},
guacamoleConfig,
statsConfig: host?.statsConfig ?? {
statusCheckEnabled: d?.statusCheckEnabled ?? true,
statusCheckInterval: 60,
@@ -236,6 +315,8 @@ export function createHostEditorForm(
"firewall",
"temperature",
],
excludedMounts: [] as string[],
monitoredMounts: [] as Array<{ path: string; label?: string }>,
},
};
}
@@ -284,6 +365,54 @@ export function buildHostEditorPayload(
const usesKey = form.authType === "key";
const usesPassword = form.authType === "password";
const usesAgent = form.authType === "agent";
const usesVault = form.authType === "vault";
const guacamoleConfig = form.inheritRemoteDesktopDefaults
? stripKeys(form.guacamoleConfig, remoteDesktopDefaultKeys)
: form.guacamoleConfig;
const terminalConfig = {
theme: form.theme,
cursorBlink: form.cursorBlink,
cursorStyle: form.cursorStyle,
fontSize: Number(form.fontSize),
fontFamily: form.fontFamily,
scrollback: Number(form.scrollback),
letterSpacing: Number(form.letterSpacing),
lineHeight: Number(form.lineHeight),
bellStyle: form.bellStyle,
rightClickSelectsWord: form.rightClickSelectsWord,
macOptionIsMeta: form.macOptionIsMeta,
fastScrollModifier: form.fastScrollModifier,
fastScrollSensitivity: Number(form.fastScrollSensitivity),
minimumContrastRatio: Number(form.minimumContrastRatio),
backspaceMode: form.backspaceMode,
startupSnippetId: form.startupSnippetId ?? null,
moshCommand: form.moshCommand || null,
agentForwarding: form.agentForwarding,
autoMosh: form.autoMosh,
autoTmux: form.autoTmux,
sudoPasswordAutoFill: form.sudoPasswordAutoFill,
sudoPassword:
form.sudoPassword === "existing_sudo_password"
? undefined
: form.sudoPassword || null,
keepaliveInterval: Number(form.keepaliveInterval),
keepaliveCountMax: Number(form.keepaliveCountMax),
environmentVariables: form.environmentVariables,
useSSHTitle: form.useSSHTitle,
syntaxHighlighting: form.syntaxHighlighting,
syntaxHighlightingOptions: form.syntaxHighlightingOptions,
backgroundImage: form.backgroundImage || null,
backgroundImageOpacity: Number(form.backgroundImageOpacity),
customThemeColors: form.theme === "custom" ? form.customThemeColors : null,
allowLegacyAlgorithms: form.allowLegacyAlgorithms,
linkClickBehavior:
form.linkClickBehavior !== "default" ? form.linkClickBehavior : undefined,
localEcho: form.localEcho !== "default" ? form.localEcho : undefined,
agentSocketPath: usesAgent ? form.agentSocketPath || null : null,
};
const terminalOverrides = form.inheritTerminalAppearance
? stripKeys(terminalConfig, terminalAppearanceKeys)
: terminalConfig;
return {
connectionType: protocols.enableSsh
@@ -304,13 +433,18 @@ export function buildHostEditorPayload(
: Number(form.telnetPort),
username: form.username,
folder: form.folder,
parentHostId: form.parentHostId ? Number(form.parentHostId) : null,
tags: form.tags,
pin: form.pin,
authType: form.authType,
useWarpgate: form.useWarpgate,
shareSshAuth: form.shareSshAuth,
password:
usesPassword || usesKey || usesCredential ? form.password || null : null,
usesPassword || usesKey || usesCredential
? form.password === "existing_password"
? undefined
: form.password || null
: null,
key: usesKey
? form.key === "existing_key"
? undefined
@@ -324,7 +458,8 @@ export function buildHostEditorPayload(
keyType: usesKey && form.keyType !== "auto" ? form.keyType : null,
credentialId:
usesCredential && form.credentialId ? Number(form.credentialId) : null,
vaultProfileId: form.vaultProfileId ? Number(form.vaultProfileId) : null,
vaultProfileId:
usesVault && form.vaultProfileId ? Number(form.vaultProfileId) : null,
overrideCredentialUsername: form.overrideCredentialUsername,
notes: form.notes,
macAddress: form.macAddress || null,
@@ -338,9 +473,17 @@ export function buildHostEditorPayload(
enableDocker: form.enableDocker,
dockerConfig: form.enableDocker ? form.dockerConfig : null,
enableTmuxMonitor: form.enableTmuxMonitor,
enableTerminalToolbar: form.enableTerminalToolbar,
allowSessionSharing: form.allowSessionSharing,
enableProxmox: form.enableProxmox,
proxmoxConfig: form.enableProxmox ? form.proxmoxConfig : null,
proxmoxConfig:
form.enableProxmox || form.proxmoxConfig?.source
? form.proxmoxConfig
: null,
enableProxmoxStats: form.enableProxmoxStats,
proxmoxStatsConfig: form.enableProxmoxStats
? form.proxmoxStatsConfig
: null,
defaultPath: form.defaultPath || "/",
useSocks5: form.useSocks5,
socks5Host:
@@ -417,7 +560,9 @@ export function buildHostEditorPayload(
form.telnetPassword !== "existing_telnet_password"
? form.telnetPassword || null
: null,
jumpHosts: form.jumpHosts,
// The editor keeps ids as strings; the API and every backend lookup take
// a number, and a string id does not compare equal on Postgres/MySQL.
jumpHosts: form.jumpHosts.map((j) => ({ hostId: Number(j.hostId) })),
portKnockSequence: form.portKnockSequence,
tunnelConnections: form.serverTunnels,
quickActions: form.quickActions.map((a) => ({
@@ -427,49 +572,9 @@ export function buildHostEditorPayload(
statsConfig: form.statsConfig,
guacamoleConfig:
(protocols.enableRdp || protocols.enableVnc || protocols.enableTelnet) &&
Object.keys(form.guacamoleConfig).length > 0
? form.guacamoleConfig
Object.keys(guacamoleConfig).length > 0
? guacamoleConfig
: null,
terminalConfig: protocols.enableSsh
? {
theme: form.theme,
cursorBlink: form.cursorBlink,
cursorStyle: form.cursorStyle,
fontSize: Number(form.fontSize),
fontFamily: form.fontFamily,
scrollback: Number(form.scrollback),
letterSpacing: Number(form.letterSpacing),
lineHeight: Number(form.lineHeight),
bellStyle: form.bellStyle,
rightClickSelectsWord: form.rightClickSelectsWord,
fastScrollModifier: form.fastScrollModifier,
fastScrollSensitivity: Number(form.fastScrollSensitivity),
minimumContrastRatio: Number(form.minimumContrastRatio),
backspaceMode: form.backspaceMode,
startupSnippetId: form.startupSnippetId ?? null,
moshCommand: form.moshCommand || null,
agentForwarding: form.agentForwarding,
autoMosh: form.autoMosh,
autoTmux: form.autoTmux,
sudoPasswordAutoFill: form.sudoPasswordAutoFill,
sudoPassword: form.sudoPassword || null,
keepaliveInterval: Number(form.keepaliveInterval),
keepaliveCountMax: Number(form.keepaliveCountMax),
environmentVariables: form.environmentVariables,
useSSHTitle: form.useSSHTitle,
syntaxHighlighting: form.syntaxHighlighting,
syntaxHighlightingOptions: form.syntaxHighlightingOptions,
backgroundImage: form.backgroundImage || null,
backgroundImageOpacity: Number(form.backgroundImageOpacity),
customThemeColors:
form.theme === "custom" ? form.customThemeColors : null,
allowLegacyAlgorithms: form.allowLegacyAlgorithms,
linkClickBehavior:
form.linkClickBehavior !== "default"
? form.linkClickBehavior
: undefined,
agentSocketPath: usesAgent ? form.agentSocketPath || null : null,
}
: null,
terminalConfig: protocols.enableSsh ? terminalOverrides : null,
};
}
+49
View File
@@ -313,6 +313,55 @@ export function HostProxmoxTab({
</SettingRow>
</>
)}
<SettingRow
label={t("hosts.enableProxmoxStats")}
description={t("hosts.enableProxmoxStatsDesc")}
>
<FakeSwitch
checked={form.enableProxmoxStats}
onChange={(v) => setField("enableProxmoxStats", v)}
/>
</SettingRow>
{form.enableProxmoxStats && (
<>
<SettingRow
label={t("hosts.proxmoxStatsPollInterval")}
description={t("hosts.proxmoxStatsPollIntervalDesc")}
>
<Input
className="w-24 h-7 text-xs"
type="number"
min={15}
value={form.proxmoxStatsConfig?.pollInterval ?? 60}
onChange={(e) =>
setField("proxmoxStatsConfig", {
...form.proxmoxStatsConfig,
pollInterval: Math.max(
15,
Number.parseInt(e.target.value || "60", 10),
),
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxStatsNodeOverride")}
description={t("hosts.proxmoxStatsNodeOverrideDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={form.proxmoxStatsConfig?.nodeName ?? ""}
placeholder={t("hosts.proxmoxStatsNodeOverridePlaceholder")}
onChange={(e) =>
setField("proxmoxStatsConfig", {
...form.proxmoxStatsConfig,
nodeName: e.target.value || null,
})
}
/>
</SettingRow>
</>
)}
</div>
</SectionCard>
);
+59 -13
View File
@@ -1,4 +1,5 @@
import React from "react";
import type { ProxyNode } from "@/types/index";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
@@ -16,6 +17,7 @@ import {
X,
} from "lucide-react";
import { FolderPathPicker } from "./FolderPathPicker";
import { HostParentPicker } from "./HostParentPicker";
import { getSSHFolders, isElectron } from "@/main-axios";
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
@@ -31,6 +33,7 @@ export function HostEditorGeneralTab({
handleProtocolToggle,
hosts,
host,
simpleMode = false,
}: {
form: HostEditorForm;
setField: HostEditorSetField;
@@ -38,9 +41,18 @@ export function HostEditorGeneralTab({
handleProtocolToggle: (proto: keyof HostProtocols, value: boolean) => void;
hosts: Host[];
host: Host | null;
/** Hides organizational/advanced fields; their values still save unchanged. */
simpleMode?: boolean;
}) {
const { t } = useTranslation();
// Tracks which picker is shown, independent of whether a value is set yet
// -- switching to "parent host" mode with nothing picked shouldn't bounce
// back to the folder picker just because parentHostId is still empty.
const [placementMode, setPlacementMode] = React.useState<
"folder" | "parentHost"
>(form.parentHostId ? "parentHost" : "folder");
const [folderMeta, setFolderMeta] = React.useState<
Map<string, { color?: string; icon?: string }>
>(new Map());
@@ -187,7 +199,7 @@ export function HostEditorGeneralTab({
onChange={(e) => setField("name", e.target.value)}
/>
</div>
{protocols.enableSsh && (
{protocols.enableSsh && !simpleMode && (
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
@@ -209,7 +221,7 @@ export function HostEditorGeneralTab({
/>
</div>
)}
{protocols.enableSsh && form.macAddress && (
{protocols.enableSsh && !simpleMode && form.macAddress && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.wolBroadcastAddress")}
@@ -243,18 +255,49 @@ export function HostEditorGeneralTab({
<SectionCard
title={t("hosts.folderAndAdvanced")}
icon={<Tag className="size-3.5" />}
className={simpleMode ? "hidden" : undefined}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 py-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.folder")}
</label>
<FolderPathPicker
value={form.folder}
onChange={(path) => setField("folder", path)}
folderPaths={folderPaths}
folderMeta={folderMeta}
/>
<div className="flex flex-col gap-1.5 col-span-2 md:col-span-1">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{placementMode === "parentHost"
? t("hosts.parentHost")
: t("hosts.folder")}
</label>
<button
type="button"
onClick={() => {
if (placementMode === "parentHost") {
setPlacementMode("folder");
setField("parentHostId", "");
} else {
setPlacementMode("parentHost");
setField("folder", "");
}
}}
className="text-[10px] text-accent-brand hover:underline"
>
{placementMode === "parentHost"
? t("hosts.useFolderInstead")
: t("hosts.useParentHostInstead")}
</button>
</div>
{placementMode === "parentHost" ? (
<HostParentPicker
value={String(form.parentHostId)}
onChange={(hostId) => setField("parentHostId", hostId)}
hosts={hosts}
excludeHostId={host?.id}
/>
) : (
<FolderPathPicker
value={form.folder}
onChange={(path) => setField("folder", path)}
folderPaths={folderPaths}
folderMeta={folderMeta}
/>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
@@ -599,7 +642,10 @@ export function HostEditorGeneralTab({
value={node.type}
onChange={(e) => {
const u = [...form.socks5ProxyChain];
u[ni] = { ...u[ni], type: e.target.value };
u[ni] = {
...u[ni],
type: e.target.value as ProxyNode["type"],
};
setField("socks5ProxyChain", u);
}}
>
@@ -678,6 +678,7 @@ export function HostEditorRdpTab({
<option>de-de-qwertz</option>
<option>de-ch-qwertz</option>
<option>fr-fr-azerty</option>
<option>fr-be-azerty</option>
<option>it-it-qwerty</option>
<option>sv-se-qwerty</option>
<option>ja-jp-qwerty</option>
@@ -1315,6 +1316,7 @@ export function HostEditorVncTab({
<option>de-de-qwertz</option>
<option>de-ch-qwertz</option>
<option>fr-fr-azerty</option>
<option>fr-be-azerty</option>
<option>it-it-qwerty</option>
<option>sv-se-qwerty</option>
<option>ja-jp-qwerty</option>
+165
View File
@@ -1,6 +1,8 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
Activity,
HardDrive,
LayoutDashboard,
Plus,
Server,
@@ -28,6 +30,38 @@ export function HostStatsTab({
snippets: { id: number; name: string }[];
}) {
const { t } = useTranslation();
const [newMount, setNewMount] = useState("");
const [newMonitoredPath, setNewMonitoredPath] = useState("");
const [newMonitoredLabel, setNewMonitoredLabel] = useState("");
const excludedMounts = form.statsConfig.excludedMounts ?? [];
const monitoredMounts = form.statsConfig.monitoredMounts ?? [];
const addExcludedMount = () => {
const value = newMount.trim();
if (!value || excludedMounts.includes(value)) {
setNewMount("");
return;
}
setField("statsConfig", {
...form.statsConfig,
excludedMounts: [...excludedMounts, value],
});
setNewMount("");
};
const addMonitoredMount = () => {
const path = newMonitoredPath.trim();
if (!path || monitoredMounts.some((entry) => entry.path === path)) return;
setField("statsConfig", {
...form.statsConfig,
monitoredMounts: [
...monitoredMounts,
{ path, label: newMonitoredLabel.trim() || undefined },
],
});
setNewMonitoredPath("");
setNewMonitoredLabel("");
};
return (
<>
@@ -143,6 +177,137 @@ export function HostStatsTab({
)}
</div>
</SectionCard>
<SectionCard
title={t("hosts.monitoredMountsLabel")}
icon={<HardDrive className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.monitoredMountsDesc")}
</p>
<div className="grid grid-cols-[1fr_0.7fr_auto] gap-2">
<Input
className="h-7 text-xs"
placeholder={t("hosts.monitoredMountPathPlaceholder")}
value={newMonitoredPath}
onChange={(e) => setNewMonitoredPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addMonitoredMount();
}
}}
/>
<Input
className="h-7 text-xs"
placeholder={t("hosts.monitoredMountLabelPlaceholder")}
value={newMonitoredLabel}
onChange={(e) => setNewMonitoredLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addMonitoredMount();
}
}}
/>
<Button
variant="outline"
size="sm"
className="h-7 px-2 text-[10px]"
onClick={addMonitoredMount}
>
<Plus className="mr-1 size-3" /> {t("hosts.addMonitoredMount")}
</Button>
</div>
{monitoredMounts.map((entry) => (
<div
key={entry.path}
className="flex items-center gap-2 border border-border bg-muted/20 p-2 group"
>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{entry.path}
</span>
{entry.label && (
<span className="truncate text-xs text-muted-foreground">
{entry.label}
</span>
)}
<button
className="text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
onClick={() =>
setField("statsConfig", {
...form.statsConfig,
monitoredMounts: monitoredMounts.filter(
(mount) => mount.path !== entry.path,
),
})
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
<SectionCard
title={t("hosts.excludedMountsLabel")}
icon={<HardDrive className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-3">
<p className="text-xs text-muted-foreground">
{t("hosts.excludedMountsDesc")}
</p>
<div className="flex items-center gap-2">
<Input
className="h-7 text-xs flex-1"
placeholder={t("hosts.excludedMountsPlaceholder")}
value={newMount}
onChange={(e) => setNewMount(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addExcludedMount();
}
}}
/>
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={addExcludedMount}
>
<Plus className="size-3 mr-1" /> {t("hosts.addExcludedMount")}
</Button>
</div>
{excludedMounts.length === 0 && (
<div className="flex flex-col items-center justify-center py-4 text-muted-foreground/40 gap-1.5">
<HardDrive className="size-6" />
<span className="text-xs">{t("hosts.excludedMountsEmpty")}</span>
</div>
)}
{excludedMounts.map((mount, i) => (
<div
key={`${mount}-${i}`}
className="flex items-center gap-2 p-2 bg-muted/20 border border-border group"
>
<span className="text-xs flex-1 font-mono truncate">{mount}</span>
<button
className="text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() =>
setField("statsConfig", {
...form.statsConfig,
excludedMounts: excludedMounts.filter(
(_, idx) => idx !== i,
),
})
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
</SectionCard>
<SectionCard
title={t("hosts.visibleWidgets")}
icon={<LayoutDashboard className="size-3.5" />}
+9 -4
View File
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../lib/error-message.js";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, Search } from "lucide-react";
@@ -12,8 +13,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { exportAllSSHHosts } from "@/main-axios";
import type { SSHHostWithStatus } from "@/main-axios";
import { exportAllSSHHosts, type SSHHostWithStatus } from "@/main-axios";
import { isFolder } from "@/sidebar/SidebarTree";
import {
buildExportPayload,
@@ -126,7 +126,7 @@ export function HostExportDialog({
.catch((err: unknown) => {
if (cancelled) return;
setRaw(null);
const message = err instanceof Error ? err.message : "";
const message = getErrorMessage(err, "");
toast.error(message || t("hosts.export.fetchFailed"));
})
.finally(() => {
@@ -326,7 +326,12 @@ export function HostExportDialog({
<Button variant="outline" onClick={onClose}>
{t("hosts.export.cancel")}
</Button>
<Button onClick={handleExport} disabled={!canExport}>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleExport}
disabled={!canExport}
>
<Download className="size-3.5 mr-2" />
{t("hosts.export.confirm")}
</Button>
+267 -102
View File
@@ -8,26 +8,31 @@ import React, {
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { ArrowLeft, Search, X } from "lucide-react";
import { ArrowLeft, ChevronDown, Search, X } from "lucide-react";
import { useUiPreference } from "@/contexts/UiPreferencesContext";
import { toast } from "sonner";
import {
getSSHHosts,
getCredentials,
getCredentialDetails,
deleteCredential,
updateCredential,
deployCredentialToHost,
renameCredentialFolder,
getLinkedCredentialIds,
} from "@/main-axios";
import type { Host, Credential } from "@/types/ui-types";
import type { SSHHostWithStatus } from "@/main-axios";
import type {
CredentialSidebarFilterState,
CredentialSortKey,
} from "@/types/credential-sidebar-preferences";
import { CredentialEditorView } from "./CredentialEditorView";
import { HostEditor } from "./HostEditor";
import { mapCredentials, sshHostToHost } from "./HostManagerData";
import { HostCredentialList } from "./HostCredentialList";
import type {
CredentialFilterState,
CredentialSortKey,
} from "./CredentialsPanel";
import { CredentialSidebarTree } from "./credential-tree";
import { sortCredentials, credentialPassesFilters } from "./credential-sort";
import {
makeCredentialTabs,
makeHostTabs,
@@ -36,43 +41,6 @@ import {
TabStrip,
} from "./HostManagerTabs";
function sortCredentials(
creds: Credential[],
key: CredentialSortKey,
): Credential[] {
if (key === "default") return creds;
const sorted = [...creds];
switch (key) {
case "name-asc":
sorted.sort((a, b) => a.name.localeCompare(b.name));
break;
case "name-desc":
sorted.sort((a, b) => b.name.localeCompare(a.name));
break;
case "username-asc":
sorted.sort((a, b) => a.username.localeCompare(b.username));
break;
case "username-desc":
sorted.sort((a, b) => b.username.localeCompare(a.username));
break;
}
return sorted;
}
function credentialPassesFilters(
cred: Credential,
filters: CredentialFilterState,
): boolean {
if (filters.type.length > 0 && !filters.type.includes(cred.type))
return false;
if (
filters.tags.length > 0 &&
!filters.tags.some((tag) => cred.tags?.includes(tag))
)
return false;
return true;
}
export function HostManager({
pendingEditId,
pendingAction,
@@ -80,7 +48,11 @@ export function HostManager({
hideListHeader,
externalSearch,
externalSort,
externalArrangeLocked = true,
externalFilter,
density = "comfortable",
trayTrigger = "hover",
showTags = true,
onTagsChange,
active = true,
}: {
@@ -90,7 +62,11 @@ export function HostManager({
hideListHeader?: boolean;
externalSearch?: string;
externalSort?: CredentialSortKey;
externalFilter?: CredentialFilterState;
externalArrangeLocked?: boolean;
externalFilter?: CredentialSidebarFilterState;
density?: "comfortable" | "compact";
trayTrigger?: "always" | "hover" | "click" | "actionsOnly";
showTags?: boolean;
onTagsChange?: (tags: string[]) => void;
active?: boolean;
} = {}) {
@@ -115,12 +91,18 @@ export function HostManager({
message: string;
onConfirm: () => void;
} | null>(null);
const [hostEditorDirty, setHostEditorDirty] = useState(false);
const [showUnsavedHostDialog, setShowUnsavedHostDialog] = useState(false);
const [editingProtocols, setEditingProtocols] = useState({
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
});
const simpleEditor = useUiPreference("hostEditor", "mode") === "simple";
// Expanding advanced is a per-session choice; fixing one host's SSH options
// shouldn't quietly move the whole app off the Simple preset.
const [showAdvancedEditor, setShowAdvancedEditor] = useState(false);
const hostsRef = useRef<Host[]>([]);
useEffect(() => {
hostsRef.current = hosts;
@@ -280,9 +262,22 @@ export function HostManager({
externalSort ?? "default",
);
const credentialFolders = Array.from(
const credentialFolderNames = Array.from(
new Set(filteredCredentials.map((c) => c.folder || "Uncategorized")),
).sort();
const credentialFolderTree = credentialFolderNames.map((name) => ({
name,
children: filteredCredentials.filter(
(c) => (c.folder || "Uncategorized") === name,
),
}));
const usedByCounts = new Map<string, number>();
for (const cred of filteredCredentials) {
usedByCounts.set(
cred.id,
allHosts.filter((h) => h.credentialId === cred.id).length,
);
}
const handleRenameCredentialFolder = async (
folder: string,
@@ -292,9 +287,28 @@ export function HostManager({
await renameCredentialFolder(folder, newName);
const res = await getCredentials();
setCredentials(mapCredentials(res));
toast.success(t("hosts.folderRenamedTo", { name: newName }));
toast.success(t("credentials.folderRenamedTo", { name: newName }));
} catch {
toast.error(t("hosts.failedToRenameFolder"));
toast.error(t("credentials.failedToRenameFolder"));
}
};
const handleMoveCredentialToFolder = async (
credentialId: string,
targetFolder: string,
) => {
const cred = credentials.find((c) => c.id === credentialId);
if (!cred || (cred.folder || "Uncategorized") === targetFolder) return;
const folderValue = targetFolder === "Uncategorized" ? "" : targetFolder;
try {
await updateCredential(Number(credentialId), { folder: folderValue });
setCredentials((prev) =>
prev.map((c) =>
c.id === credentialId ? { ...c, folder: folderValue } : c,
),
);
} catch {
toast.error(t("credentials.failedToMoveCredential"));
}
};
@@ -303,23 +317,100 @@ export function HostManager({
setCredentials((prev) => prev.filter((c) => c.id !== cred.id));
};
function handleConfirmDeleteCredential(cred: Credential) {
setConfirmDialog({
message: t("credentials.deleteCredentialConfirm", { name: cred.name }),
onConfirm: async () => {
try {
await handleDeleteCredential(cred);
toast.success(
t("credentials.deletedCredential", { name: cred.name }),
);
} catch {
toast.error(t("credentials.failedToDeleteCredential"));
}
},
});
}
async function handleEditCredential(cred: Credential) {
try {
const full = (await getCredentialDetails(Number(cred.id))) as {
hasKey?: boolean;
hasKeyPassword?: boolean;
password?: string;
certPublicKey?: string;
};
setEditingCredential({
...cred,
value: full.hasKey ? "existing_key" : (full.password ?? ""),
password: full.password ?? "",
passphrase: full.hasKeyPassword ? "existing_key_password" : "",
publicKey: full.certPublicKey ?? cred.publicKey,
});
setActiveCredentialTab("general");
} catch {
setEditingCredential(cred);
setActiveCredentialTab("general");
}
}
// Editor view: full-width with top tab bar instead of side nav
const closeHostEditor = () => {
setHostEditorDirty(false);
setShowUnsavedHostDialog(false);
setEditingHost(null);
setActiveHostTab("general");
};
const requestCloseHostEditor = () => {
if (hostEditorDirty) {
setShowUnsavedHostDialog(true);
return;
}
closeHostEditor();
};
const renderEditorView = () => {
const isHost = !!editingHost;
// Simple mode keeps General and SSH -- between them they hold everything
// needed to reach a host (name, address, username, auth) -- and hides the
// other protocol tabs plus the seven SSH sub-tabs. Nothing is lost:
// createHostEditorForm still materializes every field with its default and
// buildHostEditorPayload still serializes all of them, so hiding a tab
// hides its inputs, not its values.
const collapseAdvanced = isHost && simpleEditor && !showAdvancedEditor;
const tabs = isHost
? makeHostTabs(t).filter((tab) => {
if (tab.id === "general") return true;
if (tab.id === "ssh") return editingProtocols.enableSsh;
if (collapseAdvanced) return false;
if (tab.id === "rdp") return editingProtocols.enableRdp;
if (tab.id === "vnc") return editingProtocols.enableVnc;
if (tab.id === "telnet") return editingProtocols.enableTelnet;
return false;
})
: makeCredentialTabs(t);
const activeTab = isHost ? activeHostTab : activeCredentialTab;
// Collapsing while on a now-hidden tab would leave nothing selected. The
// SSH group collapses to its own "ssh" tab, everything else to General.
// The top-level strip only lists general/ssh/rdp/vnc/telnet -- the SSH
// sub-tabs live in the secondary strip, so they count as visible whenever
// the SSH group is expanded.
const hostTabVisible =
tabs.some((tab) => tab.id === activeHostTab) ||
(!collapseAdvanced &&
editingProtocols.enableSsh &&
SSH_GROUP_TABS.has(activeHostTab as never));
const effectiveHostTab = hostTabVisible
? activeHostTab
: collapseAdvanced && SSH_GROUP_TABS.has(activeHostTab as never)
? "ssh"
: "general";
const activeTab = isHost ? effectiveHostTab : activeCredentialTab;
const setActiveTab = isHost ? setActiveHostTab : setActiveCredentialTab;
const showSshSubTabs =
isHost &&
!collapseAdvanced &&
editingProtocols.enableSsh &&
SSH_GROUP_TABS.has(activeHostTab as never);
const sshSubTabs = makeHostSshSubTabs(t);
@@ -331,8 +422,7 @@ export function HostManager({
<button
onClick={() => {
if (isHost) {
setEditingHost(null);
setActiveHostTab("general");
requestCloseHostEditor();
} else {
setEditingCredential(null);
setActiveCredentialTab("general");
@@ -342,7 +432,9 @@ export function HostManager({
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>
{isHost ? t("hosts.backToHosts") : t("hosts.backToCredentials")}
{isHost
? t("hosts.backToHosts")
: t("credentials.backToCredentials")}
</span>
{isHost && editingHost !== "new" && (
<span
@@ -382,6 +474,22 @@ export function HostManager({
variant="secondary"
/>
)}
{isHost && simpleEditor && (
<button
onClick={() => {
if (showAdvancedEditor) setActiveHostTab("general");
setShowAdvancedEditor(!showAdvancedEditor);
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-t border-border/50"
>
<ChevronDown
className={`size-3 transition-transform ${showAdvancedEditor ? "rotate-180" : ""}`}
/>
{showAdvancedEditor
? t("hosts.hideAdvancedSettings")
: t("hosts.showAdvancedSettings")}
</button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
{isHost ? (
@@ -390,13 +498,13 @@ export function HostManager({
editingHost === "new" ? "new-host" : (editingHost as Host).id
}
host={editingHost === "new" ? null : (editingHost as Host)}
activeTab={activeHostTab}
onBack={() => {
setEditingHost(null);
setActiveHostTab("general");
}}
activeTab={effectiveHostTab}
simpleMode={collapseAdvanced}
onBack={requestCloseHostEditor}
onSave={(saved) => {
const updated = sshHostToHost(saved);
const updated = sshHostToHost(
saved as unknown as SSHHostWithStatus,
);
setHosts((prev) => {
const idx = prev.findIndex((h) => h.id === updated.id);
if (idx >= 0) {
@@ -407,13 +515,13 @@ export function HostManager({
return [...prev, updated];
});
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
setEditingHost(null);
setActiveHostTab("general");
closeHostEditor();
}}
protocols={editingProtocols}
onProtocolChange={(p) =>
setEditingProtocols((prev) => ({ ...prev, ...p }))
}
onDirtyChange={setHostEditorDirty}
onTabChange={setActiveHostTab}
hosts={hosts}
credentials={credentials}
@@ -443,20 +551,26 @@ export function HostManager({
setActiveCredentialTab("general");
}}
onSave={(saved) => {
// The save endpoints are typed as an untyped record; these are
// the fields this view reads back off the response.
const result = saved as Partial<Credential> & {
id: number | string;
authType?: string;
};
setCredentials((prev) => {
const idx = prev.findIndex((c) => c.id === String(saved.id));
const idx = prev.findIndex((c) => c.id === String(result.id));
const updated: Credential = {
id: String(saved.id),
name: saved.name,
username: saved.username ?? "",
type: saved.authType === "key" ? "key" : "password",
value: saved.value,
password: saved.password,
publicKey: saved.publicKey,
passphrase: saved.passphrase,
description: saved.description,
folder: saved.folder ?? "",
tags: saved.tags ?? [],
id: String(result.id),
name: result.name ?? "",
username: result.username ?? "",
type: result.authType === "key" ? "key" : "password",
value: result.value,
password: result.password,
publicKey: result.publicKey,
passphrase: result.passphrase,
description: result.description,
folder: result.folder ?? "",
tags: result.tags ?? [],
};
if (idx >= 0) {
const next = [...prev];
@@ -495,7 +609,7 @@ export function HostManager({
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("hosts.searchCredentialsPlaceholder")}
placeholder={t("credentials.searchCredentialsPlaceholder")}
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
/>
{searchQuery && (
@@ -510,29 +624,47 @@ export function HostManager({
</div>
)}
<HostCredentialList
credentialFolders={credentialFolders}
filteredCredentials={filteredCredentials}
credentialsLoading={credentialsLoading}
allHosts={allHosts}
editingFolderName={editingCredFolderName}
editingFolderValue={editingCredFolderValue}
termixIdLinkedIds={termixIdLinkedIds}
onEditingFolderNameChange={setEditingCredFolderName}
onEditingFolderValueChange={setEditingCredFolderValue}
onRenameFolder={handleRenameCredentialFolder}
onDeployCredential={(cred) => setDeployDialog({ cred, hostId: "" })}
onEditCredential={(cred) => {
setEditingCredential(cred);
setActiveCredentialTab("general");
}}
onDeleteCredential={handleDeleteCredential}
onAddCredential={() => {
setEditingCredential("new");
setActiveCredentialTab("general");
}}
onConfirmDialogChange={setConfirmDialog}
/>
{!credentialsLoading && filteredCredentials.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("credentials.noCredentialsFound")}
</span>
<Button
variant="outline"
size="sm"
className="mt-3 h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
onClick={() => {
setEditingCredential("new");
setActiveCredentialTab("general");
}}
>
{t("credentials.addCredentialBtn")}
</Button>
</div>
) : (
<CredentialSidebarTree
folders={credentialFolderTree}
usedByCounts={usedByCounts}
termixIdLinkedIds={termixIdLinkedIds}
query=""
loading={credentialsLoading}
arrangeLocked={externalArrangeLocked}
density={density}
trayTrigger={trayTrigger}
showTags={showTags}
editingFolderName={editingCredFolderName}
editingFolderValue={editingCredFolderValue}
onEditingFolderNameChange={setEditingCredFolderName}
onEditingFolderValueChange={setEditingCredFolderValue}
onRenameFolder={handleRenameCredentialFolder}
onMoveCredentialToFolder={handleMoveCredentialToFolder}
onDeployCredential={(cred) =>
setDeployDialog({ cred, hostId: "" })
}
onEditCredential={handleEditCredential}
onDeleteCredential={handleConfirmDeleteCredential}
/>
)}
</div>
)}
@@ -562,13 +694,42 @@ export function HostManager({
</div>
)}
{showUnsavedHostDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold text-foreground">
{t("common.unsavedChanges")}
</p>
<p className="text-xs text-muted-foreground">
{t("hosts.unsavedChangesDescription")}
</p>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowUnsavedHostDialog(false)}
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
>
{t("hosts.keepEditing")}
</button>
<button
onClick={closeHostEditor}
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
>
{t("hosts.discardChanges")}
</button>
</div>
</div>
</div>
)}
{/* Deploy credential dialog */}
{deployDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-sm flex flex-col gap-4 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-bold">
{t("hosts.deploySSHKeyTitle")}
{t("credentials.deployDialogTitle")}
</span>
<button
onClick={() => setDeployDialog(null)}
@@ -578,11 +739,13 @@ export function HostManager({
</button>
</div>
<div className="text-xs text-muted-foreground">
{t("hosts.deployDialogDesc", { name: deployDialog.cred.name })}
{t("credentials.deployDialogDesc", {
name: deployDialog.cred.name,
})}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.targetHostLabel")}
{t("credentials.targetHostLabel")}
</label>
<select
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
@@ -591,7 +754,7 @@ export function HostManager({
setDeployDialog({ ...deployDialog, hostId: e.target.value })
}
>
<option value="">{t("hosts.selectHostOption")}</option>
<option value="">{t("credentials.selectHostOption")}</option>
{allHosts
.filter(
(h) =>
@@ -626,16 +789,18 @@ export function HostManager({
Number(deployDialog.cred.id),
Number(deployDialog.hostId),
);
toast.success(t("hosts.keyDeployedSuccess"));
toast.success(t("credentials.keyDeployedSuccess"));
setDeployDialog(null);
} catch {
toast.error(t("hosts.failedToDeployKey2"));
toast.error(t("credentials.failedToDeployKey"));
} finally {
setDeploying(false);
}
}}
>
{deploying ? t("hosts.deployingBtn") : t("hosts.deployBtn")}
{deploying
? t("credentials.deployingBtn")
: t("credentials.deployBtn")}
</Button>
</div>
</div>
+26 -2
View File
@@ -2,8 +2,10 @@ import type { SSHHostWithStatus } from "@/main-axios";
import type { Host, Credential } from "@/types/ui-types";
type RawSSHHost = SSHHostWithStatus & {
hasPassword?: boolean;
hasKey?: boolean;
hasKeyPassword?: boolean;
hasSudoPassword?: boolean;
hasRdpPassword?: boolean;
hasVncPassword?: boolean;
hasTelnetPassword?: boolean;
@@ -19,6 +21,9 @@ type RawCredential = {
folder?: string | null;
tags?: string[];
publicKey?: string | null;
pin?: boolean | null;
sortOrder?: number | null;
certPublicKey?: string | null;
};
function parseJson<T>(v: unknown): T | undefined {
@@ -36,6 +41,8 @@ function parseJson<T>(v: unknown): T | undefined {
export function sshHostToHost(h: SSHHostWithStatus): Host {
const host = h as RawSSHHost;
const isSshHost = h.connectionType === "ssh" || !h.connectionType;
const parsedTerminalConfig = parseJson(h.terminalConfig) as
(Host["terminalConfig"] & { sudoPassword?: string }) | undefined;
return {
id: String(h.id),
name: h.name,
@@ -43,14 +50,21 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
ip: h.ip,
port: h.port,
folder: h.folder ?? "",
parentHostId:
(h as { parentHostId?: number | string | null }).parentHostId != null
? String((h as { parentHostId?: number | string }).parentHostId)
: null,
online: h.status === "online",
status: h.status,
cpu: null,
ram: null,
lastAccess: "",
tags: h.tags ?? [],
syncId: h.syncId ?? null,
authType: h.authType,
shareSshAuth: h.shareSshAuth ?? false,
password: h.password,
hasPassword: !!host.hasPassword || !!h.password,
hasKey: !!host.hasKey || !!(typeof h.key === "string" && h.key),
hasKeyPassword: !!host.hasKeyPassword || !!h.keyPassword,
key: typeof h.key === "string" ? h.key : undefined,
@@ -63,6 +77,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
: undefined,
notes: h.notes,
pin: h.pin ?? false,
sortOrder: h.sortOrder ?? null,
macAddress: h.macAddress,
wolBroadcastAddress: h.wolBroadcastAddress,
enableSsh: h.enableSsh != null ? h.enableSsh : isSshHost,
@@ -73,9 +88,13 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
enableTunnel: h.enableTunnel ?? false,
enableFileManager: h.enableFileManager ?? true,
enableDocker: h.enableDocker ?? false,
dockerConfig: h.dockerConfig ?? null,
enableProxmox: h.enableProxmox ?? false,
enableProxmoxStats: h.enableProxmoxStats ?? false,
enableTmuxMonitor: h.enableTmuxMonitor ?? false,
enableTerminalToolbar: h.enableTerminalToolbar ?? true,
proxmoxConfig: h.proxmoxConfig ?? null,
proxmoxStatsConfig: h.proxmoxStatsConfig ?? null,
enableRdp: h.enableRdp != null ? h.enableRdp : h.connectionType === "rdp",
enableVnc: h.enableVnc != null ? h.enableVnc : h.connectionType === "vnc",
enableTelnet:
@@ -113,7 +132,7 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
telnetUser: h.telnetUser,
telnetPassword: h.telnetPassword ?? "",
hasTelnetPassword: !!host.hasTelnetPassword || !!h.telnetPassword,
quickActions: (h.quickActions ?? []).map((a: HostQuickAction) => ({
quickActions: (h.quickActions ?? []).map((a) => ({
name: a.name,
snippetId: String(a.snippetId),
})),
@@ -123,7 +142,9 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
})),
portKnockSequence: parseJson(h.portKnockSequence) ?? [],
defaultPath: h.defaultPath,
terminalConfig: parseJson(h.terminalConfig) as Host["terminalConfig"],
terminalConfig: parsedTerminalConfig as Host["terminalConfig"],
hasSudoPassword:
!!host.hasSudoPassword || !!parsedTerminalConfig?.sudoPassword,
statsConfig: parseJson(h.statsConfig) as Host["statsConfig"],
guacamoleConfig: parseJson(h.guacamoleConfig),
forceKeyboardInteractive: h.forceKeyboardInteractive ?? false,
@@ -168,5 +189,8 @@ export function mapCredentials(res: unknown): Credential[] {
folder: c.folder ?? "",
tags: c.tags ?? [],
publicKey: c.publicKey ?? undefined,
pin: c.pin ?? false,
sortOrder: c.sortOrder ?? null,
certPublicKey: c.certPublicKey ?? undefined,
}));
}
+176
View File
@@ -0,0 +1,176 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronsUpDown, Search, Server, X } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/popover";
import type { Host } from "@/types/ui-types";
/**
* Walks parentHostId to collect every descendant id of `hostId`, so a host
* can never be assigned under its own descendant (which would form a cycle).
* Mirrors the ancestor-walk cycle guard used server-side and in buildHostTree.
*/
export function collectDescendantIds(
hostId: string,
hosts: Host[],
): Set<string> {
const childrenByParent = new Map<string, string[]>();
for (const h of hosts) {
if (!h.parentHostId) continue;
const list = childrenByParent.get(h.parentHostId) ?? [];
list.push(h.id);
childrenByParent.set(h.parentHostId, list);
}
const out = new Set<string>();
const stack = [...(childrenByParent.get(hostId) ?? [])];
while (stack.length > 0) {
const id = stack.pop()!;
if (out.has(id)) continue;
out.add(id);
stack.push(...(childrenByParent.get(id) ?? []));
}
return out;
}
export function HostParentPicker({
value,
onChange,
hosts,
excludeHostId,
}: {
value: string;
onChange: (hostId: string) => void;
hosts: Host[];
/** The host being edited, if any -- excluded from the list along with its descendants. */
excludeHostId?: string | null;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const excludedIds = useMemo(() => {
if (!excludeHostId) return new Set<string>();
const descendants = collectDescendantIds(excludeHostId, hosts);
descendants.add(excludeHostId);
return descendants;
}, [excludeHostId, hosts]);
const candidates = useMemo(
() =>
hosts
.filter((h) => !excludedIds.has(h.id))
.sort((a, b) => a.name.localeCompare(b.name)),
[hosts, excludedIds],
);
const query = search.trim().toLowerCase();
const filtered = query
? candidates.filter(
(h) =>
h.name.toLowerCase().includes(query) ||
h.ip.toLowerCase().includes(query),
)
: candidates;
const selected = value ? hosts.find((h) => h.id === value) : undefined;
function commit(hostId: string) {
onChange(hostId);
setSearch("");
setOpen(false);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 h-8 w-full min-w-0 rounded-none border border-input bg-transparent px-2.5 text-xs transition-colors hover:border-ring/60 focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 outline-none"
>
{selected ? (
<span className="flex items-center gap-1.5 min-w-0 flex-1">
<Server className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate">{selected.name || selected.ip}</span>
</span>
) : (
<span className="flex-1 text-left text-muted-foreground">
{t("hosts.parentHostPickerPlaceholder")}
</span>
)}
{value && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
onChange("");
}}
className="shrink-0 text-muted-foreground/50 hover:text-foreground"
>
<X className="size-3" />
</span>
)}
<ChevronsUpDown className="size-3 shrink-0 text-muted-foreground/50" />
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={4}
collisionPadding={8}
className="w-max min-w-(--radix-popover-trigger-width) max-w-96 max-h-(--radix-popover-content-available-height) p-0 rounded-none border-0 ring-1 ring-border shadow-md flex flex-col overflow-hidden"
>
<div className="flex items-center gap-2 border-b border-border px-2.5 h-8 shrink-0">
<Search className="size-3 shrink-0 text-muted-foreground/60" />
<input
autoFocus
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && filtered.length > 0) {
e.preventDefault();
commit(filtered[0].id);
}
}}
placeholder={t("hosts.parentHostPickerSearch")}
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto py-1">
{value && (
<button
type="button"
onClick={() => commit("")}
className="flex items-center gap-2 w-full px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
<X className="size-3.5 shrink-0" />
{t("hosts.parentHostPickerNone")}
</button>
)}
{filtered.map((h) => (
<button
key={h.id}
type="button"
onClick={() => commit(h.id)}
className={`flex items-center gap-2 w-full px-2.5 py-1.5 text-xs hover:bg-accent hover:text-accent-foreground transition-colors ${
h.id === value
? "bg-accent/60 text-foreground"
: "text-foreground/80"
}`}
>
<Server className="size-3.5 shrink-0 text-muted-foreground/70" />
<span className="truncate flex-1 min-w-0">{h.name || h.ip}</span>
<span className="text-[10px] text-muted-foreground/50 truncate shrink-0">
{h.ip}
</span>
</button>
))}
{filtered.length === 0 && (
<div className="py-4 text-center text-xs text-muted-foreground">
{t("hosts.parentHostPickerEmpty")}
</div>
)}
</div>
</PopoverContent>
</Popover>
);
}
+26 -11
View File
@@ -98,7 +98,9 @@ export function HostShareModal({
setSharingLoaded(true);
Promise.all([
host
? getHostAccess(Number(host.id)).catch(() => ({ accessList: [] }))
? getHostAccess(Number(host.id), host.syncId).catch(() => ({
accessList: [],
}))
: Promise.resolve({ accessList: [] }),
getUserList().catch(() => ({ users: [] })),
getRoles().catch(() => ({ roles: [] })),
@@ -171,7 +173,7 @@ export function HostShareModal({
async function refreshAccessList() {
if (!host) return;
const res = await getHostAccess(Number(host.id));
const res = await getHostAccess(Number(host.id), host.syncId);
setAccessList(res.accessList ?? []);
}
@@ -206,11 +208,15 @@ export function HostShareModal({
}),
);
} else if (host) {
await shareHost(Number(host.id), {
targets,
permissionLevel,
...(durationHours ? { durationHours } : {}),
});
await shareHost(
Number(host.id),
{
targets,
permissionLevel,
...(durationHours ? { durationHours } : {}),
},
host.syncId,
);
await refreshAccessList();
setSelectedUserIds(new Set());
setSelectedRoleIds(new Set());
@@ -233,9 +239,14 @@ export function HostShareModal({
) {
if (!host || record.permissionLevel === level) return;
try {
await updateHostAccess(Number(host.id), record.id, {
permissionLevel: level,
});
await updateHostAccess(
Number(host.id),
record.id,
{
permissionLevel: level,
},
host.syncId,
);
setAccessList((prev) =>
prev.map((entry) =>
entry.id === record.id ? { ...entry, permissionLevel: level } : entry,
@@ -560,7 +571,11 @@ export function HostShareModal({
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
onClick={async () => {
try {
await revokeHostAccess(Number(host!.id), record.id);
await revokeHostAccess(
Number(host!.id),
record.id,
host!.syncId,
);
setAccessList((prev) =>
prev.filter((entry) => entry.id !== record.id),
);
File diff suppressed because it is too large. Load diff
+227
View File
@@ -0,0 +1,227 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
Gauge,
Layers,
RotateCcw,
Sparkles,
Wrench,
} from "lucide-react";
import { Button } from "@/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
import {
UI_AREA_KEYS,
type UiAreaKey,
type UiPreset,
} from "@/types/ui-preferences";
const SELECTABLE_PRESETS: Exclude<UiPreset, "custom">[] = [
"simple",
"balanced",
"advanced",
];
const PRESET_ICONS = {
simple: Sparkles,
balanced: Gauge,
advanced: Wrench,
} as const;
/** Areas that have nothing worth listing as a per-area override. */
const HIDDEN_OVERRIDE_AREAS = new Set<UiAreaKey>(["homepage"]);
export function InterfacePresetSettings({
onRunSetupAgain,
}: {
onRunSetupAgain?: () => void;
}) {
const { t } = useTranslation();
const ctx = useUiPreferencesContext();
const [pendingPreset, setPendingPreset] = useState<Exclude<
UiPreset,
"custom"
> | null>(null);
const [applying, setApplying] = useState(false);
// The chosen preset stays highlighted even once knobs diverge from it --
// the customizations are listed separately below.
const activeLabel = ctx?.preferences.preset ?? "balanced";
const customizedAreas = useMemo(() => {
if (!ctx) return [] as UiAreaKey[];
return UI_AREA_KEYS.filter(
(area) =>
!HIDDEN_OVERRIDE_AREAS.has(area) &&
Object.keys(ctx.preferences.overrides[area] ?? {}).length > 0,
);
}, [ctx]);
if (!ctx) return null;
async function confirmPreset() {
if (!pendingPreset) return;
setApplying(true);
try {
ctx.setPreset(pendingPreset);
// Picking a preset is a deliberate reset, so any knob the user had
// pinned goes back to following the preset.
ctx.clearAllOverrides();
await applyPresetSideEffects(pendingPreset);
} finally {
setApplying(false);
setPendingPreset(null);
}
}
return (
<div className="flex flex-col gap-3">
<p className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.interfacePresetDesc")}
</p>
{/* Stacked rather than a grid: this panel lives in the sidebar, which
can be as narrow as 160px, so side-by-side cards would wrap. */}
<div className="flex flex-col gap-1.5">
{SELECTABLE_PRESETS.map((preset) => {
const Icon = PRESET_ICONS[preset];
const active = activeLabel === preset;
return (
<button
key={preset}
type="button"
onClick={() => setPendingPreset(preset)}
aria-pressed={active}
className={`flex w-full items-start gap-2 border p-2 text-left transition-colors ${
active
? "border-accent-brand bg-accent-brand/10"
: "border-border bg-card hover:bg-muted/40"
}`}
>
<Icon
size={13}
className={`mt-0.5 shrink-0 ${
active ? "text-accent-brand" : "text-muted-foreground"
}`}
/>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="text-xs font-semibold">
{t(`newUi.sidebar.userProfile.preset_${preset}`)}
</span>
<span className="text-[10px] leading-snug text-muted-foreground">
{t(`newUi.sidebar.userProfile.preset_${preset}_desc`)}
</span>
</span>
{active && (
<Check
size={12}
className="mt-0.5 shrink-0 text-accent-brand"
/>
)}
</button>
);
})}
</div>
{customizedAreas.length > 0 && (
<div className="flex flex-col gap-1 border border-border bg-card p-2">
<span className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<Layers size={11} className="shrink-0" />
<span className="min-w-0 truncate">
{t("newUi.sidebar.userProfile.presetOverridesTitle")}
</span>
</span>
{customizedAreas.map((area) => (
<div
key={area}
className="flex items-center justify-between gap-2 py-0.5"
>
<span className="min-w-0 flex-1 truncate text-xs">
{t(`newUi.sidebar.userProfile.uiArea_${area}`)}
</span>
<button
type="button"
onClick={() => ctx.clearArea(area)}
className="shrink-0 text-[10px] text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
{t("newUi.sidebar.userProfile.presetRevertArea")}
</button>
</div>
))}
<Button
variant="outline"
size="sm"
className="mt-1 h-7 w-full text-[10px]"
onClick={() => ctx.clearAllOverrides()}
>
<RotateCcw size={11} />
{t("newUi.sidebar.userProfile.presetClearOverrides")}
</Button>
</div>
)}
{onRunSetupAgain && (
<div className="flex flex-col gap-1.5 border-t border-border pt-3">
<span className="text-[10px] leading-snug text-muted-foreground">
{t("newUi.sidebar.userProfile.runSetupAgainDesc")}
</span>
<Button
variant="outline"
size="sm"
className="h-7 w-full text-[10px]"
onClick={onRunSetupAgain}
>
{t("newUi.sidebar.userProfile.runSetupAgainButton")}
</Button>
</div>
)}
<Dialog
open={!!pendingPreset}
onOpenChange={(open) => !open && setPendingPreset(null)}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-base font-bold">
{t("newUi.sidebar.userProfile.presetConfirmTitle", {
preset: pendingPreset
? t(`newUi.sidebar.userProfile.preset_${pendingPreset}`)
: "",
})}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.userProfile.presetConfirmDesc")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setPendingPreset(null)}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={applying}
onClick={confirmPreset}
>
{t("newUi.sidebar.userProfile.presetConfirmApply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+856
View File
@@ -0,0 +1,856 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Braces,
ChevronDown,
Clock,
CornerDownRight,
GitBranch,
Plus,
Repeat,
Save,
Square,
Terminal as TerminalIcon,
Trash2,
Play,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Textarea } from "@/components/textarea";
import { Checkbox } from "@/components/checkbox";
import { EmptyState } from "@/components/empty-state";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
import { getErrorMessage } from "../lib/error-message.js";
import {
parseTerminalMacros,
runTerminalMacro,
type MacroStep,
type TerminalMacro,
} from "@/lib/terminal-macros";
import type { Tab } from "@/types/ui-types";
const STEP_TYPES = ["send", "wait", "delay", "if", "repeat"] as const;
const STEP_ICONS = {
send: TerminalIcon,
wait: CornerDownRight,
delay: Clock,
if: GitBranch,
repeat: Repeat,
} as const;
function id(): string {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function createStep(type: MacroStep["type"]): MacroStep {
const stepId = id();
switch (type) {
case "send":
return { id: stepId, type, text: "", pressEnter: true };
case "delay":
return { id: stepId, type, milliseconds: 1000 };
case "wait":
return {
id: stepId,
type,
pattern: "",
isRegex: false,
timeoutMs: 10_000,
onTimeout: "stop",
};
case "if":
return {
id: stepId,
type,
pattern: "",
isRegex: false,
then: [],
else: [],
};
case "repeat":
return { id: stepId, type, count: 2, steps: [] };
}
}
function isValidRegex(pattern: string): boolean {
try {
new RegExp(pattern);
return true;
} catch {
return false;
}
}
/** Milliseconds are an implementation detail, so the editor works in seconds. */
function SecondsInput({
valueMs,
onChangeMs,
minMs,
label,
}: {
valueMs: number;
onChangeMs: (ms: number) => void;
minMs: number;
label: string;
}) {
const { t } = useTranslation();
return (
<label className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{label}</span>
<Input
className="h-7 w-20 rounded-none text-xs"
type="number"
min={minMs / 1000}
max={300}
step={0.5}
value={valueMs / 1000}
onChange={(event) => {
const seconds = Number(event.target.value);
onChangeMs(
Number.isFinite(seconds)
? Math.max(minMs, Math.min(300_000, Math.round(seconds * 1000)))
: minMs,
);
}}
/>
<span className="text-muted-foreground">{t("macros.seconds")}</span>
</label>
);
}
/** Shared text/regex matcher field used by both "wait" and "if". */
function MatchField({
pattern,
isRegex,
label,
placeholder,
onChange,
}: {
pattern: string;
isRegex: boolean;
label: string;
placeholder: string;
onChange: (next: { pattern?: string; isRegex?: boolean }) => void;
}) {
const { t } = useTranslation();
const badRegex = isRegex && pattern.length > 0 && !isValidRegex(pattern);
return (
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">{label}</div>
<Input
className={`h-8 rounded-none font-mono text-xs ${
badRegex ? "border-destructive" : ""
}`}
placeholder={placeholder}
value={pattern}
onChange={(event) => onChange({ pattern: event.target.value })}
/>
{badRegex && (
<div className="text-[11px] text-destructive">
{t("macros.match.invalidRegex")}
</div>
)}
<label className="flex items-center gap-2 text-[11px] text-muted-foreground">
<Checkbox
checked={isRegex}
onCheckedChange={(checked) => onChange({ isRegex: checked === true })}
/>
{t("macros.match.useRegex")}
</label>
</div>
);
}
function AddStepMenu({
onAdd,
compact,
}: {
onAdd: (type: MacroStep["type"]) => void;
compact?: boolean;
}) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="sm"
variant="outline"
className={`rounded-none ${compact ? "h-7 text-[11px]" : "h-8 w-full text-xs"}`}
>
<Plus className="mr-1 size-3" />
{t("macros.addStep")}
<ChevronDown className="ml-1 size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72 rounded-none">
{STEP_TYPES.map((type) => {
const Icon = STEP_ICONS[type];
return (
<DropdownMenuItem
key={type}
className="flex-col items-start gap-0.5 rounded-none"
onSelect={() => onAdd(type)}
>
<span className="flex items-center gap-2 text-xs font-medium">
<Icon className="size-3.5" />
{t(`macros.stepTypes.${type}.label`)}
</span>
<span className="pl-5 text-[11px] leading-snug text-muted-foreground">
{t(`macros.stepTypes.${type}.help`)}
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
function StepList({
steps,
onChange,
depth = 0,
}: {
steps: MacroStep[];
onChange: (steps: MacroStep[]) => void;
depth?: number;
}) {
const { t } = useTranslation();
const update = (index: number, step: MacroStep) => {
const next = [...steps];
next[index] = step;
onChange(next);
};
return (
<div className="flex flex-col gap-2">
{steps.map((step, index) => {
const Icon = STEP_ICONS[step.type];
return (
<div key={step.id} className="border border-border bg-muted/20">
<div className="flex items-center gap-2 border-b border-border/60 px-2 py-1.5">
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="text-xs font-medium">
{t(`macros.stepTypes.${step.type}.label`)}
</span>
<Button
size="icon"
variant="ghost"
className="ml-auto size-6 rounded-none"
aria-label={t("macros.removeStep")}
onClick={() => onChange(steps.filter((_, i) => i !== index))}
>
<Trash2 className="size-3" />
</Button>
</div>
<div className="space-y-2 p-2">
{step.type === "send" && (
<div className="space-y-2">
<Textarea
className="min-h-16 rounded-none font-mono text-xs"
placeholder={t("macros.send.textPlaceholder")}
value={step.text}
onChange={(event) =>
update(index, { ...step, text: event.target.value })
}
/>
<label className="flex items-center gap-2 text-[11px] text-muted-foreground">
<Checkbox
checked={step.pressEnter}
onCheckedChange={(checked) =>
update(index, { ...step, pressEnter: checked === true })
}
/>
{t("macros.send.pressEnter")}
</label>
</div>
)}
{step.type === "delay" && (
<SecondsInput
label={t("macros.delay.duration")}
valueMs={step.milliseconds}
minMs={0}
onChangeMs={(milliseconds) =>
update(index, { ...step, milliseconds })
}
/>
)}
{step.type === "wait" && (
<div className="space-y-2">
<MatchField
pattern={step.pattern}
isRegex={step.isRegex !== false}
label={t("macros.wait.pattern")}
placeholder={t("macros.wait.patternPlaceholder")}
onChange={(next) => update(index, { ...step, ...next })}
/>
<SecondsInput
label={t("macros.wait.timeout")}
valueMs={step.timeoutMs}
minMs={100}
onChangeMs={(timeoutMs) =>
update(index, { ...step, timeoutMs })
}
/>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">
{t("macros.wait.onTimeout")}
</div>
<div className="flex gap-1">
{(["stop", "continue"] as const).map((mode) => (
<Button
key={mode}
size="sm"
variant={
step.onTimeout === mode ? "default" : "outline"
}
className="h-7 flex-1 rounded-none text-[11px]"
onClick={() =>
update(index, { ...step, onTimeout: mode })
}
>
{t(`macros.wait.${mode}`)}
</Button>
))}
</div>
</div>
</div>
)}
{step.type === "if" && (
<div className="space-y-3">
<MatchField
pattern={step.pattern}
isRegex={step.isRegex !== false}
label={t("macros.ifStep.pattern")}
placeholder={t("macros.ifStep.patternPlaceholder")}
onChange={(next) => update(index, { ...step, ...next })}
/>
<div className="border-l-2 border-emerald-500/40 pl-2">
<div className="mb-1 text-[11px] font-medium text-emerald-500">
{t("macros.ifStep.then")}
</div>
<StepList
steps={step.then}
depth={depth + 1}
onChange={(then) => update(index, { ...step, then })}
/>
</div>
<div className="border-l-2 border-amber-500/40 pl-2">
<div className="mb-1 text-[11px] font-medium text-amber-500">
{t("macros.ifStep.else")}
</div>
<StepList
steps={step.else}
depth={depth + 1}
onChange={(elseSteps) =>
update(index, { ...step, else: elseSteps })
}
/>
</div>
</div>
)}
{step.type === "repeat" && (
<div className="space-y-2">
<label className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">
{t("macros.repeat.count")}
</span>
<Input
className="h-7 w-20 rounded-none text-xs"
type="number"
min={1}
max={100}
value={step.count}
onChange={(event) =>
update(index, {
...step,
count: Math.max(
1,
Math.min(100, Number(event.target.value) || 1),
),
})
}
/>
<span className="text-muted-foreground">
{t("macros.repeat.times")}
</span>
</label>
<div className="border-l-2 border-border pl-2">
<StepList
steps={step.steps}
depth={depth + 1}
onChange={(nested) =>
update(index, { ...step, steps: nested })
}
/>
</div>
</div>
)}
</div>
</div>
);
})}
{depth < 4 && (
<AddStepMenu
compact={depth > 0}
onAdd={(type) => onChange([...steps, createStep(type)])}
/>
)}
</div>
);
}
export function MacrosPanel({
terminalTabs,
activeTabId,
storageMode,
}: {
terminalTabs: Tab[];
activeTabId: string;
storageMode: "local" | "cloud";
}) {
const { t } = useTranslation();
const [macros, setMacros] = useState<TerminalMacro[]>([]);
const [draft, setDraft] = useState<TerminalMacro | null>(null);
const [dirty, setDirty] = useState(false);
const [runningId, setRunningId] = useState<string | null>(null);
const [pendingDelete, setPendingDelete] = useState<TerminalMacro | null>(
null,
);
const [pendingRun, setPendingRun] = useState<TerminalMacro | null>(null);
const abortRef = useRef<AbortController | null>(null);
const confirmedRef = useRef<Set<string>>(new Set());
useEffect(() => {
let cancelled = false;
const load =
storageMode === "local"
? Promise.resolve(
parseTerminalMacros(localStorage.getItem("terminalMacros")),
)
: getUserPreferences().then((preferences) =>
parseTerminalMacros(preferences.terminalMacros),
);
load
.then((loaded) => {
if (!cancelled) setMacros(loaded);
})
.catch(() => {
if (!cancelled) toast.error(t("macros.loadFailed"));
});
return () => {
cancelled = true;
abortRef.current?.abort();
};
}, [storageMode, t]);
const target = useMemo(
() => terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0],
[activeTabId, terminalTabs],
);
const updateDraft = useCallback((next: TerminalMacro) => {
setDraft(next);
setDirty(true);
}, []);
const createMacro = () => {
const now = new Date().toISOString();
setDraft({
id: id(),
name: "",
description: "",
steps: [],
createdAt: now,
updatedAt: now,
});
setDirty(true);
};
const persist = async (next: TerminalMacro[]) => {
const serialized = JSON.stringify(next);
if (storageMode === "local")
localStorage.setItem("terminalMacros", serialized);
else await saveUserPreferences({ terminalMacros: serialized });
setMacros(next);
};
const save = async () => {
if (!draft) return;
if (!draft.name.trim()) {
toast.error(t("macros.nameRequired"));
return;
}
const saved: TerminalMacro = {
...draft,
name: draft.name.trim(),
updatedAt: new Date().toISOString(),
};
try {
await persist(
macros.some((macro) => macro.id === saved.id)
? macros.map((macro) => (macro.id === saved.id ? saved : macro))
: [...macros, saved],
);
setDraft(saved);
setDirty(false);
toast.success(t("macros.saved"));
} catch (error) {
toast.error(getErrorMessage(error) || t("macros.saveFailed"));
}
};
const confirmDelete = async () => {
if (!pendingDelete) return;
const next = macros.filter((macro) => macro.id !== pendingDelete.id);
try {
await persist(next);
if (draft?.id === pendingDelete.id) {
setDraft(null);
setDirty(false);
}
toast.success(t("macros.deleted"));
} catch (error) {
toast.error(getErrorMessage(error) || t("macros.saveFailed"));
} finally {
setPendingDelete(null);
}
};
const execute = useCallback(
async (macro: TerminalMacro) => {
const terminal = target?.terminalRef?.current;
if (!terminal?.sendInput || !terminal.subscribeOutput) {
toast.error(t("macros.needTerminal"));
return;
}
const controller = new AbortController();
abortRef.current = controller;
setRunningId(macro.id);
try {
await runTerminalMacro(
macro,
{ send: terminal.sendInput, subscribe: terminal.subscribeOutput },
{ signal: controller.signal },
);
toast.success(t("macros.completed"));
} catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) {
toast.error(getErrorMessage(error) || t("macros.failed"));
}
} finally {
abortRef.current = null;
setRunningId(null);
}
},
[target, t],
);
const requestRun = (macro: TerminalMacro) => {
if (!target?.terminalRef?.current?.sendInput) {
toast.error(t("macros.needTerminal"));
return;
}
if (confirmedRef.current.has(macro.id)) {
void execute(macro);
return;
}
setPendingRun(macro);
};
const targetLabel = target?.label ?? t("macros.noTerminal");
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center gap-2 border-b border-border p-3">
{draft ? (
<Button
size="icon"
variant="ghost"
className="size-7 rounded-none"
aria-label={t("macros.back")}
onClick={() => {
setDraft(null);
setDirty(false);
}}
>
<ArrowLeft className="size-4" />
</Button>
) : (
<Braces className="size-4 shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">{t("macros.title")}</div>
<div className="truncate text-[11px] text-muted-foreground">
{t("macros.target")}: {targetLabel}
</div>
{!draft && (
<a
href="https://docs.termix.site/features/terminal/macros"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
)}
</div>
{!draft && (
<Button
size="icon"
variant="outline"
className="size-7 rounded-none"
aria-label={t("macros.create")}
onClick={createMacro}
>
<Plus className="size-3.5" />
</Button>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{!draft ? (
macros.length === 0 ? (
<EmptyState
icon={Braces}
title={t("macros.empty")}
hint={t("macros.emptyHint")}
action={
<Button
size="sm"
variant="outline"
className="mt-1 rounded-none"
onClick={createMacro}
>
<Plus className="mr-1 size-3" />
{t("macros.create")}
</Button>
}
/>
) : (
<div className="flex flex-col gap-1">
{macros.map((macro) => {
const isRunning = runningId === macro.id;
return (
<div
key={macro.id}
className="group flex items-center gap-2 border border-border bg-muted/20 px-2 py-1.5 hover:bg-muted/40"
>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => {
setDraft(structuredClone(macro));
setDirty(false);
}}
>
<div className="truncate text-xs font-medium">
{macro.name}
</div>
{macro.description && (
<div className="truncate text-[11px] text-muted-foreground">
{macro.description}
</div>
)}
</button>
<Button
size="icon"
variant="ghost"
className="size-6 shrink-0 rounded-none"
aria-label={
isRunning ? t("macros.stop") : t("macros.run")
}
onClick={() =>
isRunning
? abortRef.current?.abort()
: requestRun(macro)
}
>
{isRunning ? (
<Square className="size-3" />
) : (
<Play className="size-3" />
)}
</Button>
</div>
);
})}
</div>
)
) : (
<div className="space-y-3">
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">
{t("macros.name")}
</div>
<Input
className="h-8 rounded-none text-xs"
value={draft.name}
placeholder={t("macros.namePlaceholder")}
onChange={(event) =>
updateDraft({ ...draft, name: event.target.value })
}
/>
</div>
<div className="space-y-1.5">
<div className="text-xs text-muted-foreground">
{t("macros.descriptionLabel")}
</div>
<Textarea
className="min-h-14 rounded-none text-xs"
value={draft.description ?? ""}
placeholder={t("macros.descriptionPlaceholder")}
onChange={(event) =>
updateDraft({ ...draft, description: event.target.value })
}
/>
</div>
{draft.steps.length === 0 && (
<div className="border border-dashed border-border px-3 py-4 text-center text-[11px] text-muted-foreground">
{t("macros.noSteps")}
</div>
)}
<StepList
steps={draft.steps}
onChange={(steps) => updateDraft({ ...draft, steps })}
/>
<div className="flex items-center gap-2 border-t border-border pt-3">
<Button
size="sm"
variant="destructive"
className="rounded-none"
onClick={() => setPendingDelete(draft)}
>
<Trash2 className="mr-1 size-3" />
{t("macros.delete")}
</Button>
<Button
size="sm"
variant="outline"
className="rounded-none"
onClick={save}
>
<Save className="mr-1 size-3" />
{t("macros.save")}
</Button>
<Button
size="sm"
className="ml-auto rounded-none"
onClick={() =>
runningId === draft.id
? abortRef.current?.abort()
: requestRun(draft)
}
>
{runningId === draft.id ? (
<>
<Square className="mr-1 size-3" />
{t("macros.stop")}
</>
) : (
<>
<Play className="mr-1 size-3" />
{t("macros.run")}
</>
)}
</Button>
</div>
{dirty && (
<div className="text-[11px] text-amber-500">
{t("macros.unsaved")}
</div>
)}
</div>
)}
</div>
<AlertDialog
open={pendingDelete !== null}
onOpenChange={(open) => !open && setPendingDelete(null)}
>
<AlertDialogContent className="rounded-none">
<AlertDialogHeader>
<AlertDialogTitle>{t("macros.deleteTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("macros.deleteBody", { name: pendingDelete?.name ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="rounded-none">
{t("macros.cancel")}
</AlertDialogCancel>
<AlertDialogAction className="rounded-none" onClick={confirmDelete}>
{t("macros.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={pendingRun !== null}
onOpenChange={(open) => !open && setPendingRun(null)}
>
<AlertDialogContent className="rounded-none">
<AlertDialogHeader>
<AlertDialogTitle>{t("macros.confirmRunTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("macros.confirmRunBody", {
name: pendingRun?.name ?? "",
host: targetLabel,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="rounded-none">
{t("macros.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="rounded-none"
onClick={() => {
const macro = pendingRun;
setPendingRun(null);
if (macro) {
confirmedRef.current.add(macro.id);
void execute(macro);
}
}}
>
{t("macros.confirmRunAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { PanelRight, SquareArrowOutUpRight, X } from "lucide-react";
const STORAGE_KEY = "termix_multiPanelHintDismissed";
function multiPanelHintDismissed(): boolean {
return localStorage.getItem(STORAGE_KEY) === "true";
}
/**
* One-time nudge telling users a panel can leave the sidebar. Both actions are
* otherwise only reachable through modifier clicks and the rail context menu,
* which nobody finds on their own.
*/
export function MultiPanelHint({
canPromote,
canRightDock,
onOpenAsTab,
onOpenInRightDock,
}: {
canPromote: boolean;
canRightDock: boolean;
onOpenAsTab: () => void;
onOpenInRightDock: () => void;
}) {
const { t } = useTranslation();
const [dismissed, setDismissed] = useState(multiPanelHintDismissed);
if (dismissed || (!canPromote && !canRightDock)) return null;
function dismiss() {
localStorage.setItem(STORAGE_KEY, "true");
setDismissed(true);
}
return (
<div className="relative shrink-0 border-b border-border bg-accent-brand/5 px-3 py-2.5">
<button
onClick={dismiss}
title={t("common.dismiss")}
aria-label={t("common.dismiss")}
className="absolute right-1.5 top-1.5 text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
</button>
<p className="pr-5 text-xs font-semibold text-foreground">
{t("nav.multiPanelHintTitle")}
</p>
<p className="mt-0.5 pr-5 text-[11px] leading-snug text-muted-foreground">
{t("nav.multiPanelHintBody")}
</p>
<div className="mt-2 flex flex-col gap-1.5">
{canPromote && (
<div className="flex items-start gap-1.5 text-[11px] leading-snug text-muted-foreground">
<SquareArrowOutUpRight className="mt-0.5 size-3 shrink-0 text-accent-brand" />
<span>
<button
onClick={() => {
dismiss();
onOpenAsTab();
}}
className="font-medium text-foreground underline underline-offset-2 hover:text-accent-brand"
>
{t("nav.openAsTab")}
</button>
{": "}
{t("nav.multiPanelHintTabHow")}
</span>
</div>
)}
{canRightDock && (
<div className="flex items-start gap-1.5 text-[11px] leading-snug text-muted-foreground">
<PanelRight className="mt-0.5 size-3 shrink-0 text-accent-brand" />
<span>
<button
onClick={() => {
dismiss();
onOpenInRightDock();
}}
className="font-medium text-foreground underline underline-offset-2 hover:text-accent-brand"
>
{t("nav.openInRightDock")}
</button>
{": "}
{t("nav.multiPanelHintDockHow")}
</span>
</div>
)}
</div>
</div>
);
}
+88 -4
View File
@@ -23,7 +23,7 @@ interface NotificationChannelDialogProps {
onSaved: () => void;
}
type ChannelType = "webhook" | "ntfy";
type ChannelType = "webhook" | "ntfy" | "discord";
interface WebhookConfig {
url: string;
@@ -36,6 +36,12 @@ interface NtfyConfig {
token?: string;
}
interface DiscordConfig {
url: string;
username?: string;
avatar_url?: string;
}
export function NotificationChannelDialog({
open,
onOpenChange,
@@ -49,6 +55,8 @@ export function NotificationChannelDialog({
const [ntfyUrl, setNtfyUrl] = useState("https://ntfy.sh");
const [ntfyTopic, setNtfyTopic] = useState("");
const [ntfyToken, setNtfyToken] = useState("");
const [discordUsername, setDiscordUsername] = useState("");
const [discordAvatarUrl, setDiscordAvatarUrl] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
@@ -60,6 +68,11 @@ export function NotificationChannelDialog({
const cfg = JSON.parse(channel.config);
if (channel.type === "webhook") {
setWebhookUrl((cfg as WebhookConfig).url ?? "");
} else if (channel.type === "discord") {
const dc = cfg as DiscordConfig;
setWebhookUrl(dc.url ?? "");
setDiscordUsername(dc.username ?? "");
setDiscordAvatarUrl(dc.avatar_url ?? "");
} else {
const nc = cfg as NtfyConfig;
setNtfyUrl(nc.url ?? "https://ntfy.sh");
@@ -84,7 +97,7 @@ export function NotificationChannelDialog({
toast.error(t("alerts.channelNameRequired", "Name is required"));
return;
}
let config: WebhookConfig | NtfyConfig;
let config: WebhookConfig | NtfyConfig | DiscordConfig;
if (type === "webhook") {
if (!webhookUrl.trim()) {
toast.error(t("alerts.webhookUrlRequired", "Webhook URL is required"));
@@ -93,6 +106,29 @@ export function NotificationChannelDialog({
config = {
url: webhookUrl.trim(),
} satisfies WebhookConfig;
} else if (type === "discord") {
const trimmedUrl = webhookUrl.trim();
if (!trimmedUrl) {
toast.error(t("alerts.webhookUrlRequired", "Webhook URL is required"));
return;
}
if (
!/^https:\/\/(?:canary\.|ptb\.)?(?:discord\.com|discordapp\.com)\/api\/webhooks\/.+/i.test(
trimmedUrl,
)
) {
toast.error(
t(
"alerts.discordUrlInvalid",
"Discord URL must be a valid Discord webhook URL",
),
);
return;
}
const dc: DiscordConfig = { url: trimmedUrl };
if (discordUsername.trim()) dc.username = discordUsername.trim();
if (discordAvatarUrl.trim()) dc.avatar_url = discordAvatarUrl.trim();
config = dc;
} else {
if (!ntfyTopic.trim()) {
toast.error(t("alerts.ntfyTopicRequired", "Topic is required"));
@@ -150,13 +186,17 @@ export function NotificationChannelDialog({
{t("alerts.channelType", "Type")}
</label>
<div className="flex gap-2">
{(["webhook", "ntfy"] as ChannelType[]).map((ct) => (
{(["webhook", "ntfy", "discord"] as ChannelType[]).map((ct) => (
<button
key={ct}
onClick={() => setType(ct)}
className={`px-3 py-1 text-xs font-semibold border transition-colors ${type === ct ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:border-accent-brand/30"}`}
>
{ct === "webhook" ? "Webhook" : "ntfy"}
{ct === "webhook"
? "Webhook"
: ct === "ntfy"
? "ntfy"
: "Discord"}
</button>
))}
</div>
@@ -182,6 +222,50 @@ export function NotificationChannelDialog({
</div>
)}
{type === "discord" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.webhookUrl", "Discord webhook URL")}
</label>
<Input
value={webhookUrl}
onChange={(e) => setWebhookUrl(e.target.value)}
placeholder="https://discord.com/api/webhooks/..."
className="text-sm font-mono"
/>
<span className="text-[10px] text-muted-foreground">
{t(
"alerts.discordWebhookDesc",
"POST a Discord embed to this webhook URL on each alert firing",
)}
</span>
<div className="flex flex-col gap-1 pt-2">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.discordUsername", "Display name (optional)")}
</label>
<Input
value={discordUsername}
onChange={(e) => setDiscordUsername(e.target.value)}
placeholder="Termix"
className="text-sm"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("alerts.discordAvatar", "Avatar URL (optional)")}
</label>
<Input
value={discordAvatarUrl}
onChange={(e) => setDiscordAvatarUrl(e.target.value)}
placeholder="https://example.com/avatar.png"
className="text-sm font-mono"
/>
</div>
</div>
)}
{type === "ntfy" && (
<>
<div className="flex flex-col gap-1">
+22
View File
@@ -0,0 +1,22 @@
/**
* Drop indicator for sidebar drag-to-reorder.
*
* Drawn once by the tree at an absolute offset from the virtualizer's own
* slot geometry, never by the rows themselves. Rows can't place this
* correctly: in hover mode the hovered row expands its action tray while the
* row below stays collapsed, so a bar at the hovered row's bottom edge and
* one at the next row's top edge land in two different places. That produced
* two candidate lines whose visibility flickered with the pointer.
*/
export function ReorderIndicator({ top }: { top: number }) {
return (
<div
aria-hidden
className="absolute inset-x-0 h-0.5 bg-accent-brand pointer-events-none z-30"
style={{ top: top - 1 }}
>
<span className="absolute -left-px -top-[3px] size-2 bg-accent-brand" />
<span className="absolute -right-px -top-[3px] size-2 bg-accent-brand" />
</div>
);
}
+9 -4
View File
@@ -9,8 +9,12 @@ import {
DialogTitle,
} from "@/components/dialog";
import { toast } from "sonner";
import type { SSOProvider, SSOProviderType } from "@/types/index";
import type { OIDCProviderConfig, LDAPProviderConfig } from "@/types/index";
import {
type LDAPProviderConfig,
type OIDCProviderConfig,
type SSOProvider,
type SSOProviderType,
} from "@/types/index";
import { createSSOProvider, updateSSOProvider } from "@/api/sso-provider-api";
type ApiErrorLike = {
@@ -75,7 +79,7 @@ function emptyOidc(): OIDCProviderConfig {
};
}
function emptyLdap(): LDAPProviderConfig {
function emptyLdap(): Required<LDAPProviderConfig> {
return {
host: "",
port: 389,
@@ -382,7 +386,8 @@ export function SSOProviderDialog({
</Button>
<Button
size="sm"
className="text-xs bg-accent-brand text-white hover:bg-accent-brand/90"
variant="outline"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
disabled={saving}
>
+125 -39
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
import { Input } from "@/components/input";
import {
Tooltip,
@@ -15,6 +15,7 @@ import {
Copy,
Download,
Eye,
FileText,
Loader2,
Save,
ScrollText,
@@ -32,6 +33,10 @@ import {
type SessionLogRecord,
} from "@/api/session-log-api";
import { SessionRecordingPlayer } from "@/features/session-recording/SessionRecordingPlayer";
import {
asciicastToPlainText,
parseAsciicast,
} from "@/features/session-recording/asciicast";
function formatDuration(seconds: number | null): string {
if (seconds == null) return "--";
@@ -78,6 +83,20 @@ function buildFilename(log: SessionLogRecord): string {
return `${host}_${y}-${m}-${day}_${h}-${min}-${s}.${extension}`;
}
function buildTextFilename(log: SessionLogRecord): string {
return buildFilename(log).replace(/\.(cast|guac|log)$/, ".txt");
}
async function extractPlainText(
log: SessionLogRecord,
blob: Blob,
): Promise<string | null> {
if (log.format === "guacamole") return null;
const source = await blob.text();
if (log.format === "text") return source;
return asciicastToPlainText(parseAsciicast(source));
}
function SectionHeader({ label, count }: { label: string; count: number }) {
return (
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/20">
@@ -95,13 +114,16 @@ function LogRow({
log,
onView,
onDownload,
onDownloadText,
onDelete,
}: {
log: SessionLogRecord;
onView: () => void;
onDownload: () => void;
onDownloadText: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const hostLabel = log.hostName ?? log.hostIp ?? `Host ${log.hostId}`;
return (
@@ -139,6 +161,21 @@ function LogRow({
</TooltipTrigger>
<TooltipContent side="left">View log</TooltipContent>
</Tooltip>
{log.format === "asciicast" && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onDownloadText}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<FileText className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{t("sessionLogs.downloadAsText")}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -175,6 +212,7 @@ export function SessionLogsPanel() {
const [viewLog, setViewLog] = useState<SessionLogRecord | null>(null);
const [viewContent, setViewContent] = useState<string>("");
const [viewBlob, setViewBlob] = useState<Blob | null>(null);
const [viewText, setViewText] = useState<string | null>(null);
const [viewLoading, setViewLoading] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<SessionLogRecord | null>(
null,
@@ -183,46 +221,51 @@ export function SessionLogsPanel() {
const [copied, setCopied] = useState(false);
const [retentionDays, setRetentionDays] = useState<number | null>(null);
const [savingRetention, setSavingRetention] = useState(false);
const logsRef = useRef(logs);
logsRef.current = logs;
const load = useCallback(
(initial = false) => {
async (initial = false) => {
if (initial) setLoading(true);
getSessionLogs()
.then((fresh) => {
setLogs((prev) => {
if (
prev.length === fresh.length &&
prev.every((log, i) => log.id === fresh[i]?.id)
) {
return prev;
}
return fresh;
});
})
.catch(() => {
if (initial) toast.error(t("sessionLogs.loadError"));
})
.finally(() => {
if (initial) setLoading(false);
});
try {
const fresh = await getSessionLogs();
const changed =
logsRef.current.length !== fresh.length ||
logsRef.current.some((log, i) => log.id !== fresh[i]?.id);
if (changed) {
logsRef.current = fresh;
setLogs(fresh);
}
return changed;
} catch (error) {
if (initial) {
toast.error(t("sessionLogs.loadError"));
return false;
}
throw error;
} finally {
if (initial) setLoading(false);
}
},
[t],
);
useEffect(() => {
load(true);
void load(true);
getSessionRecordingRetention()
.then(setRetentionDays)
.catch(() => setRetentionDays(null));
}, [load]);
usePageVisibleInterval(
() => {
void load(false);
useAdaptivePolling(
() => load(false),
{
minIntervalMs: 5_000,
maxIntervalMs: 30_000,
stablePollsPerStep: 3,
},
5_000,
true,
{ runOnMount: false },
{ runImmediately: false },
);
const q = filter.trim().toLowerCase();
@@ -238,12 +281,17 @@ export function SessionLogsPanel() {
setViewLog(log);
setViewContent("");
setViewBlob(null);
setViewText(null);
setViewLoading(true);
try {
if (log.format === "text") {
setViewContent(await getSessionLogContent(log.id));
const text = await getSessionLogContent(log.id);
setViewContent(text);
setViewText(text);
} else {
setViewBlob(await getSessionLogBlob(log.id));
const blob = await getSessionLogBlob(log.id);
setViewBlob(blob);
setViewText(await extractPlainText(log, blob));
}
} catch {
toast.error(t("sessionLogs.loadError"));
@@ -252,15 +300,37 @@ export function SessionLogsPanel() {
}
};
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
const handleDownload = async (log: SessionLogRecord) => {
try {
const blob = await getSessionLogBlob(log.id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = buildFilename(log);
a.click();
URL.revokeObjectURL(url);
downloadBlob(await getSessionLogBlob(log.id), buildFilename(log));
} catch {
toast.error(t("sessionLogs.loadError"));
}
};
const handleDownloadText = async (log: SessionLogRecord) => {
try {
const text =
log.id === viewLog?.id && viewText != null
? viewText
: await extractPlainText(log, await getSessionLogBlob(log.id));
if (text == null) {
toast.error(t("sessionLogs.loadError"));
return;
}
downloadBlob(
new Blob([text], { type: "text/plain" }),
buildTextFilename(log),
);
} catch {
toast.error(t("sessionLogs.loadError"));
}
@@ -281,8 +351,8 @@ export function SessionLogsPanel() {
};
const handleCopy = async () => {
if (!viewContent) return;
const ok = await copyToClipboard(viewContent);
if (!viewText) return;
const ok = await copyToClipboard(viewText);
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
@@ -329,7 +399,7 @@ export function SessionLogsPanel() {
</div>
<TooltipProvider>
<div className="flex items-center gap-0.5 shrink-0">
{viewLog.format === "text" && (
{viewText != null && (
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -350,6 +420,21 @@ export function SessionLogsPanel() {
</TooltipContent>
</Tooltip>
)}
{viewLog.format === "asciicast" && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => handleDownloadText(viewLog)}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 transition-colors"
>
<FileText className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{t("sessionLogs.downloadAsText")}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -470,6 +555,7 @@ export function SessionLogsPanel() {
log={log}
onView={() => handleView(log)}
onDownload={() => handleDownload(log)}
onDownloadText={() => handleDownloadText(log)}
onDelete={() => setDeleteTarget(log)}
/>
))}
File diff suppressed because it is too large. Load diff
+444 -156
View File
@@ -1,7 +1,8 @@
import { getErrorMessage } from "../lib/error-message.js";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { useSnippetRunner } from "@/hooks/use-snippet-runner.tsx";
import {
getSnippets,
createSnippet as apiCreateSnippet,
@@ -18,7 +19,13 @@ import {
getUserList,
getRoles,
reorderSnippets,
saveUserPreferences,
} from "@/main-axios";
import {
hasSnippetInputs,
type SnippetHostContext,
} from "@/lib/snippet-variables";
import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog";
import {
exportSnippets,
importSnippets,
@@ -41,6 +48,9 @@ import {
import {
Box,
ChevronDown,
ChevronsDownUp,
ChevronsUpDown,
Clipboard,
Copy,
Cpu,
Database,
@@ -70,6 +80,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { SettingRow, FakeSwitch } from "@/components/section-card";
import { toast } from "sonner";
import { FOLDER_COLORS } from "@/lib/theme";
import { FOLDER_ICONS } from "@/types/ui-types";
@@ -147,6 +158,7 @@ function SnippetFormDialog({
const [description, setDescription] = useState("");
const [folder, setFolder] = useState<string | null>(null);
const [content, setContent] = useState("");
const [isNote, setIsNote] = useState(false);
const [selectedHostIds, setSelectedHostIds] = useState<Set<number>>(
new Set(),
);
@@ -165,6 +177,7 @@ function SnippetFormDialog({
setDescription(snippet?.description ?? "");
setFolder(snippet?.folder ?? null);
setContent(snippet?.content ?? "");
setIsNote(snippet?.isNote ?? false);
setSelectedHostIds(new Set(snippet?.hostIds ?? []));
}
}, [open, snippet]);
@@ -189,8 +202,11 @@ function SnippetFormDialog({
description: description.trim() || undefined,
content: content.trim(),
folder,
isNote,
hostIds:
selectedHostIds.size > 0 ? Array.from(selectedHostIds) : undefined,
!isNote && selectedHostIds.size > 0
? Array.from(selectedHostIds)
: undefined,
},
snippet?.id,
);
@@ -254,79 +270,113 @@ function SnippetFormDialog({
folderMeta={folderMeta}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold text-muted-foreground">
{t("newUi.sidebar.snippets.typeLabel")}
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setIsNote(false)}
className={`flex-1 py-1.5 text-xs border transition-colors ${!isNote ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{t("newUi.sidebar.snippets.typeCommand")}
</button>
<button
type="button"
onClick={() => setIsNote(true)}
className={`flex-1 py-1.5 text-xs border transition-colors ${isNote ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{t("newUi.sidebar.snippets.typeNote")}
</button>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.snippets.commandLabel")}{" "}
{isNote
? t("newUi.sidebar.snippets.noteLabel")
: t("newUi.sidebar.snippets.commandLabel")}{" "}
<span className="text-accent-brand">*</span>
</label>
<textarea
placeholder={t("newUi.sidebar.snippets.commandPlaceholder")}
placeholder={
isNote
? t("newUi.sidebar.snippets.notePlaceholder")
: t("newUi.sidebar.snippets.commandPlaceholder")
}
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full h-36 px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
<Server className="size-3.5" />
{t("newUi.sidebar.snippets.targetHostsLabel")}{" "}
<span className="font-normal">
({t("newUi.sidebar.snippets.optional")})
</span>
</label>
<p className="text-xs text-muted-foreground/70">
{t("newUi.sidebar.snippets.targetHostsHint")}
<p className="text-[10px] text-muted-foreground/70 font-mono">
{t("newUi.sidebar.snippets.variablesHint")}
</p>
{availableHosts.length === 0 ? (
<span className="text-xs text-muted-foreground/50">
{t("newUi.sidebar.snippets.noHostsAvailable")}
</span>
) : (
<div className="flex flex-col gap-1 max-h-32 overflow-y-auto border border-border p-1.5">
{availableHosts.map((host) => {
const selected = selectedHostIds.has(host.id);
return (
<button
key={host.id}
type="button"
onClick={() => toggleHost(host.id)}
className={`flex items-center gap-2 px-2 py-1.5 text-left transition-colors ${
selected
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground"
}`}
>
<div
className={`size-3 border-2 flex items-center justify-center shrink-0 transition-colors ${
</div>
{!isNote && (
<div className="flex flex-col gap-1.5">
<label className="flex items-center gap-2 text-xs font-semibold text-muted-foreground">
<Server className="size-3.5" />
{t("newUi.sidebar.snippets.targetHostsLabel")}{" "}
<span className="font-normal">
({t("newUi.sidebar.snippets.optional")})
</span>
</label>
<p className="text-xs text-muted-foreground/70">
{t("newUi.sidebar.snippets.targetHostsHint")}
</p>
{availableHosts.length === 0 ? (
<span className="text-xs text-muted-foreground/50">
{t("newUi.sidebar.snippets.noHostsAvailable")}
</span>
) : (
<div className="flex flex-col gap-1 max-h-32 overflow-y-auto border border-border p-1.5">
{availableHosts.map((host) => {
const selected = selectedHostIds.has(host.id);
return (
<button
key={host.id}
type="button"
onClick={() => toggleHost(host.id)}
className={`flex items-center gap-2 px-2 py-1.5 text-left transition-colors ${
selected
? "border-accent-brand bg-accent-brand"
: "border-border/60"
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground"
}`}
>
{selected && <div className="size-1.5 bg-background" />}
</div>
<Server className="size-3 shrink-0 opacity-60" />
<span className="text-xs font-medium truncate flex-1">
{host.name || host.ip}
</span>
<span className="text-xs text-muted-foreground/60 shrink-0">
{host.ip}
</span>
</button>
);
})}
</div>
)}
{selectedHostIds.size > 0 && (
<button
type="button"
onClick={() => setSelectedHostIds(new Set())}
className="text-xs text-muted-foreground hover:text-foreground self-start"
>
{t("newUi.sidebar.snippets.clearTargetHosts")}
</button>
)}
</div>
<div
className={`size-3 border-2 flex items-center justify-center shrink-0 transition-colors ${
selected
? "border-accent-brand bg-accent-brand"
: "border-border/60"
}`}
>
{selected && (
<div className="size-1.5 bg-background" />
)}
</div>
<Server className="size-3 shrink-0 opacity-60" />
<span className="text-xs font-medium truncate flex-1">
{host.name || host.ip}
</span>
<span className="text-xs text-muted-foreground/60 shrink-0">
{host.ip}
</span>
</button>
);
})}
</div>
)}
{selectedHostIds.size > 0 && (
<button
type="button"
onClick={() => setSelectedHostIds(new Set())}
className="text-xs text-muted-foreground hover:text-foreground self-start"
>
{t("newUi.sidebar.snippets.clearTargetHosts")}
</button>
)}
</div>
)}
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
@@ -624,6 +674,7 @@ function mapRawSnippet(s: RawSnippet): Snippet {
folder: s.folder ?? null,
order: s.order ?? 0,
hostIds: parseHostFilter(s.hostFilter),
isNote: s.isNote ?? false,
};
}
type RawSnippetFolder = {
@@ -817,7 +868,7 @@ function SnippetCard({
onDelete,
onEdit,
onShare,
onConfirmRun,
onRunSnippet,
onDirectExecute,
onDragStart,
onDragEnd,
@@ -834,7 +885,7 @@ function SnippetCard({
onDelete: (id: number) => void;
onEdit: (snippet: Snippet) => void;
onShare: (snippet: Snippet) => void;
onConfirmRun: (snippet: Snippet, execute: () => void) => void;
onRunSnippet: (snippet: Snippet, targets: Tab[]) => void;
onDirectExecute: (snippet: Snippet) => void;
onDragStart: () => void;
onDragEnd: () => void;
@@ -844,34 +895,20 @@ function SnippetCard({
availableHosts: SSHHost[];
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
const hasTargetHosts = (snippet.hostIds?.length ?? 0) > 0;
const hasTargetHosts = !snippet.isNote && (snippet.hostIds?.length ?? 0) > 0;
function executeRun() {
function handleRun() {
if (hasTargetHosts) {
onDirectExecute(snippet);
return;
}
const targets = terminalTabs.filter((tab) => selectedTabIds.has(tab.id));
if (targets.length > 0) {
targets.forEach((tab) => {
tab.terminalRef?.current?.sendInput?.(snippet.content + "\r");
});
toast.success(
t("newUi.sidebar.snippets.runSuccess", {
name: snippet.name,
count: targets.length,
}),
);
onRunSnippet(snippet, targets);
} else if (terminalTabs.length > 0) {
const activeTab =
terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0];
activeTab.terminalRef?.current?.sendInput?.(snippet.content + "\r");
toast.success(
t("newUi.sidebar.snippets.runSuccess", {
name: snippet.name,
count: 1,
}),
);
onRunSnippet(snippet, [activeTab]);
} else {
toast.error(t("newUi.sidebar.snippets.noTerminalTabsOpen"));
}
@@ -880,10 +917,6 @@ function SnippetCard({
}
}
function handleRun() {
onConfirmRun(snippet, executeRun);
}
function handleCopy() {
copyToClipboard(snippet.content);
toast.success(
@@ -921,10 +954,15 @@ function SnippetCard({
</span>
)}
</div>
{snippet.isNote && (
<span className="shrink-0 mt-0.5 text-[10px] px-1.5 py-0.5 bg-muted/40 text-muted-foreground border border-border">
{t("newUi.sidebar.snippets.typeNote")}
</span>
)}
{hasTargetHosts && (
<Zap
className="size-3 shrink-0 mt-0.5 text-accent-brand/70"
title={t("newUi.sidebar.snippets.hasTargetHosts")}
aria-label={t("newUi.sidebar.snippets.hasTargetHosts")}
/>
)}
</div>
@@ -953,12 +991,16 @@ function SnippetCard({
>
{hasTargetHosts ? (
<Zap className="size-3" />
) : snippet.isNote ? (
<Clipboard className="size-3" />
) : (
<Play className="size-3" />
)}
{hasTargetHosts
? t("newUi.sidebar.snippets.runOnTargets")
: t("newUi.sidebar.snippets.run")}
: snippet.isNote
? t("newUi.sidebar.snippets.pasteToTerminal")
: t("newUi.sidebar.snippets.run")}
</Button>
<Button
variant="ghost"
@@ -1211,7 +1253,7 @@ function VirtualFolderGroup({
onDelete,
onEdit,
onShare,
onConfirmRun,
onRunSnippet,
onDirectExecute,
draggedSnippet,
dropTarget,
@@ -1221,7 +1263,8 @@ function VirtualFolderGroup({
onDrop,
availableHosts,
t,
defaultOpen,
open,
onToggleOpen,
}: {
folderName: string;
snippets: Snippet[];
@@ -1231,7 +1274,7 @@ function VirtualFolderGroup({
onDelete: (id: number) => void;
onEdit: (snippet: Snippet) => void;
onShare: (snippet: Snippet) => void;
onConfirmRun: (snippet: Snippet, execute: () => void) => void;
onRunSnippet: (snippet: Snippet, targets: Tab[]) => void;
onDirectExecute: (snippet: Snippet) => void;
draggedSnippet: Snippet | null;
dropTarget: { id: number; position: "above" | "below" } | null;
@@ -1241,14 +1284,13 @@ function VirtualFolderGroup({
onDrop: (folder: string | null) => void;
availableHosts: SSHHost[];
t: (key: string, opts?: Record<string, unknown>) => string;
defaultOpen: boolean;
open: boolean;
onToggleOpen: () => void;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="flex flex-col gap-2">
<button
onClick={() => setOpen((v) => !v)}
onClick={onToggleOpen}
className="flex items-center gap-1.5 w-full text-left"
>
<ChevronDown
@@ -1278,7 +1320,7 @@ function VirtualFolderGroup({
onDelete={onDelete}
onEdit={onEdit}
onShare={onShare}
onConfirmRun={onConfirmRun}
onRunSnippet={onRunSnippet}
onDirectExecute={onDirectExecute}
onDragStart={() => onDragStart(snippet)}
onDragEnd={onDragEnd}
@@ -1297,15 +1339,28 @@ function VirtualFolderGroup({
);
}
const UNCATEGORIZED_KEY = "__uncategorized__";
function readOpenFolders(): Set<string> {
try {
const saved = localStorage.getItem("snippetOpenFolders");
if (saved) return new Set(JSON.parse(saved));
} catch {
// ignore malformed storage
}
return new Set();
}
export function SnippetsPanel({
terminalTabs,
activeTabId,
storageMode = "local",
}: {
terminalTabs: Tab[];
activeTabId: string;
storageMode?: "local" | "cloud";
}) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [snippetSearch, setSnippetSearch] = useState("");
const [folders, setFolders] = useState<SnippetFolder[]>([]);
const [snippets, setSnippets] = useState<Snippet[]>([]);
@@ -1323,6 +1378,12 @@ export function SnippetsPanel({
[],
);
const [executionSnippetName, setExecutionSnippetName] = useState("");
const [foldersCollapsedDefault, setFoldersCollapsedDefault] = useState(
() => localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
);
const [confirmExecution, setConfirmExecution] = useState(
() => localStorage.getItem("confirmSnippetExecution") === "true",
);
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
() =>
new Set(
@@ -1331,6 +1392,22 @@ export function SnippetsPanel({
: [],
),
);
// Path B (direct SSH execute) needs its own variables-dialog state since it
// resolves against the first target host and posts inputValues to the
// backend, unlike Path A (terminal send) which the shared hook covers.
const [directRunningSnippet, setDirectRunningSnippet] = useState<{
snippet: Snippet;
host: SnippetHostContext | null;
onConfirm: (
resolvedContent: string,
inputValues: Record<string, string>,
) => void;
} | null>(null);
const {
runSnippet: handleRunSnippet,
handleConfirmRun,
dialog: runSnippetDialog,
} = useSnippetRunner();
function updateSnippets(next: Snippet[] | ((prev: Snippet[]) => Snippet[])) {
setSnippets((prev) => {
@@ -1343,23 +1420,69 @@ export function SnippetsPanel({
const getFoldersCollapsed = () =>
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
const [uncategorizedOpen, setUncategorizedOpen] = useState(
() => !getFoldersCollapsed(),
const [openFolders, setOpenFolders] = useState<Set<string>>(() =>
readOpenFolders(),
);
function persistOpenFolders(next: Set<string>) {
try {
localStorage.setItem("snippetOpenFolders", JSON.stringify([...next]));
} catch {
// ignore quota/serialization failures
}
}
function toggleOpenFolder(key: string) {
setOpenFolders((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
persistOpenFolders(next);
return next;
});
}
const allFolderKeys = useMemo(() => {
const keys = new Set<string>([UNCATEGORIZED_KEY]);
for (const f of folders) keys.add(f.name);
for (const s of snippets) {
if (s.folder) keys.add(s.folder);
}
return keys;
}, [folders, snippets]);
useEffect(() => {
const handler = () => {
const collapsed = getFoldersCollapsed();
setUncategorizedOpen(!collapsed);
setFolders((prev) => prev.map((f) => ({ ...f, open: !collapsed })));
const next = collapsed ? new Set<string>() : new Set(allFolderKeys);
persistOpenFolders(next);
setOpenFolders(next);
};
const expandAll = () => {
const next = new Set(allFolderKeys);
persistOpenFolders(next);
setOpenFolders(next);
};
const collapseAll = () => {
const next = new Set<string>();
persistOpenFolders(next);
setOpenFolders(next);
};
window.addEventListener("defaultSnippetFoldersCollapsedChanged", handler);
return () =>
window.addEventListener("snippets:expand-all", expandAll);
window.addEventListener("snippets:collapse-all", collapseAll);
return () => {
window.removeEventListener(
"defaultSnippetFoldersCollapsedChanged",
handler,
);
}, []);
window.removeEventListener("snippets:expand-all", expandAll);
window.removeEventListener("snippets:collapse-all", collapseAll);
};
}, [allFolderKeys]);
const [draggedSnippet, setDraggedSnippet] = useState<Snippet | null>(null);
const [dropTarget, setDropTarget] = useState<{
@@ -1426,32 +1549,43 @@ export function SnippetsPanel({
setDropTarget(null);
}
const handleConfirmRun = useCallback(
(snippet: Snippet, execute: () => void) => {
const shouldConfirm =
localStorage.getItem("confirmSnippetExecution") === "true";
if (!shouldConfirm) {
execute();
return;
// For folder keys the user has never explicitly toggled (not present in the
// persisted set), fall back to the default-collapsed setting instead of
// always treating them as closed.
function seedNewFolderKeys(keys: string[]) {
if (keys.length === 0) return;
const collapsed = getFoldersCollapsed();
if (collapsed) return;
setOpenFolders((prev) => {
const hadAny = localStorage.getItem("snippetOpenFolders") !== null;
const next = new Set(prev);
let changed = false;
for (const key of keys) {
if (!hadAny || !next.has(key)) {
if (!next.has(key)) {
next.add(key);
changed = true;
}
}
}
confirmWithToast(
t("newUi.sidebar.snippets.confirmRunMessage", { name: snippet.name }),
execute,
t("newUi.sidebar.snippets.confirmRunButton"),
t("newUi.sidebar.snippets.cancel"),
{ confirmOnEnter: true, duration: 6000 },
);
},
[confirmWithToast, t],
);
if (!changed) return prev;
persistOpenFolders(next);
return next;
});
}
useEffect(() => {
const collapsed = getFoldersCollapsed();
getSnippets()
.then((data) => {
const arr: RawSnippet[] = Array.isArray(data) ? data : [];
updateSnippets(arr.map(mapRawSnippet));
const arr = (Array.isArray(data)
? data
: []) as unknown as RawSnippet[];
const mapped = arr.map(mapRawSnippet);
updateSnippets(mapped);
const folderKeys = Array.from(
new Set(mapped.map((s) => s.folder ?? UNCATEGORIZED_KEY)),
);
seedNewFolderKeys(folderKeys);
})
.catch(() => {});
@@ -1463,9 +1597,10 @@ export function SnippetsPanel({
name: f.name,
color: f.color ?? FOLDER_COLORS[0],
icon: (f.icon as FolderIconId) ?? "folder",
open: !collapsed,
open: true,
}));
setFolders(mapped);
seedNewFolderKeys(mapped.map((f) => f.name));
})
.catch(() => {});
@@ -1535,18 +1670,18 @@ export function SnippetsPanel({
const id =
typeof created.id === "number" ? created.id : Number(created.id);
setFolders((prev) => [...prev, { ...f, id, open: true }]);
setOpenFolders((prev) => {
const next = new Set(prev);
next.add(f.name);
persistOpenFolders(next);
return next;
});
toast.success(t("newUi.sidebar.snippets.folderCreateSuccess"));
} catch {
toast.error(t("newUi.sidebar.snippets.folderCreateFailed"));
}
}
function toggleFolder(id: number) {
setFolders((prev) =>
prev.map((f) => (f.id === id ? { ...f, open: !f.open } : f)),
);
}
async function handleDeleteFolder(folder: SnippetFolder) {
try {
await apiDeleteSnippetFolder(folder.name);
@@ -1556,6 +1691,13 @@ export function SnippetsPanel({
s.folder === folder.name ? { ...s, folder: null } : s,
),
);
setOpenFolders((prev) => {
if (!prev.has(folder.name)) return prev;
const next = new Set(prev);
next.delete(folder.name);
persistOpenFolders(next);
return next;
});
toast.success(
t("newUi.sidebar.snippets.folderDeleteSuccess", { name: folder.name }),
);
@@ -1577,6 +1719,15 @@ export function SnippetsPanel({
s.folder === oldName ? { ...s, folder: data.name } : s,
),
);
setOpenFolders((prev) => {
const next = new Set(prev);
if (next.has(oldName)) {
next.delete(oldName);
next.add(data.name);
}
persistOpenFolders(next);
return next;
});
}
await apiUpdateSnippetFolderMetadata(nameChanged ? data.name : oldName, {
color: data.color,
@@ -1614,11 +1765,16 @@ export function SnippetsPanel({
}
function reloadData() {
const collapsed = getFoldersCollapsed();
getSnippets()
.then((data) => {
const arr: RawSnippet[] = Array.isArray(data) ? data : [];
updateSnippets(arr.map(mapRawSnippet));
const arr = (Array.isArray(data)
? data
: []) as unknown as RawSnippet[];
const mapped = arr.map(mapRawSnippet);
updateSnippets(mapped);
seedNewFolderKeys(
Array.from(new Set(mapped.map((s) => s.folder ?? UNCATEGORIZED_KEY))),
);
})
.catch(() => {});
getSnippetFolders()
@@ -1629,9 +1785,10 @@ export function SnippetsPanel({
name: f.name,
color: f.color ?? FOLDER_COLORS[0],
icon: (f.icon as FolderIconId) ?? "folder",
open: !collapsed,
open: true,
}));
setFolders(mapped);
seedNewFolderKeys(mapped.map((f) => f.name));
})
.catch(() => {});
}
@@ -1650,7 +1807,10 @@ export function SnippetsPanel({
setSnippetFormOpen(true);
}
async function handleDirectExecute(snippet: Snippet) {
async function executeDirectRun(
snippet: Snippet,
inputValues: Record<string, string>,
) {
const hostIds = snippet.hostIds ?? [];
if (hostIds.length === 0) return;
@@ -1663,7 +1823,11 @@ export function SnippetsPanel({
const host = availableHosts.find((h) => h.id === hostId);
const hostLabel = host ? host.name || host.ip : String(hostId);
try {
const result = await apiExecuteSnippet(snippet.id, hostId);
const result = await apiExecuteSnippet(
snippet.id,
hostId,
Object.keys(inputValues).length > 0 ? inputValues : undefined,
);
return {
hostLabel,
success: result.success,
@@ -1675,7 +1839,7 @@ export function SnippetsPanel({
hostLabel,
success: false,
output: "",
error: err instanceof Error ? err.message : String(err),
error: getErrorMessage(err, String(err)),
};
}
}),
@@ -1700,6 +1864,40 @@ export function SnippetsPanel({
}
}
const handleDirectExecute = useCallback(
(snippet: Snippet) => {
const runWithInputs = (inputValues: Record<string, string>) => {
handleConfirmRun(snippet, () => {
void executeDirectRun(snippet, inputValues);
});
};
if (hasSnippetInputs(snippet.content)) {
const firstHost = snippet.hostIds?.length
? availableHosts.find((h) => h.id === snippet.hostIds![0])
: undefined;
setDirectRunningSnippet({
snippet,
host: firstHost
? {
ip: firstHost.ip,
username: firstHost.username,
port: firstHost.port,
name: firstHost.name,
}
: null,
onConfirm: (_resolvedContent, inputValues) => {
setDirectRunningSnippet(null);
runWithInputs(inputValues);
},
});
} else {
runWithInputs({});
}
},
[availableHosts, handleConfirmRun],
);
const filtered = snippetSearch
? snippets.filter(
(s) =>
@@ -1726,14 +1924,91 @@ export function SnippetsPanel({
<span className="text-xs font-semibold">
{t("newUi.sidebar.snippets.title")}
</span>
<a
href="https://docs.termix.site/features/terminal/snippets"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
<div className="flex items-center gap-2">
<a
href="https://docs.termix.site/features/terminal/snippets"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
<button
onClick={() =>
window.dispatchEvent(new CustomEvent("snippets:expand-all"))
}
title={t("newUi.sidebar.snippets.expandAll")}
className="size-6 flex items-center justify-center text-muted-foreground hover:text-foreground"
>
<ChevronsUpDown className="size-3.5" />
</button>
<button
onClick={() =>
window.dispatchEvent(new CustomEvent("snippets:collapse-all"))
}
title={t("newUi.sidebar.snippets.collapseAll")}
className="size-6 flex items-center justify-center text-muted-foreground hover:text-foreground"
>
<ChevronsDownUp className="size-3.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
title={t("newUi.sidebar.snippets.settings")}
className="size-6 flex items-center justify-center text-muted-foreground hover:text-foreground"
>
<Settings className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="text-xs w-64 p-2">
<SettingRow
label={t("newUi.sidebar.userProfile.foldersCollapsed")}
description={t(
"newUi.sidebar.userProfile.foldersCollapsedDesc",
)}
>
<FakeSwitch
checked={foldersCollapsedDefault}
onChange={(v) => {
setFoldersCollapsedDefault(v);
localStorage.setItem(
"defaultSnippetFoldersCollapsed",
v.toString(),
);
window.dispatchEvent(
new Event("defaultSnippetFoldersCollapsedChanged"),
);
if (storageMode === "cloud")
saveUserPreferences({ foldersCollapsed: v }).catch(
() => {},
);
}}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.confirmExecution")}
description={t(
"newUi.sidebar.userProfile.confirmExecutionDesc",
)}
>
<FakeSwitch
checked={confirmExecution}
onChange={(v) => {
setConfirmExecution(v);
localStorage.setItem(
"confirmSnippetExecution",
v.toString(),
);
if (storageMode === "cloud")
saveUserPreferences({
confirmSnippetExecution: v,
}).catch(() => {});
}}
/>
</SettingRow>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
@@ -1857,11 +2132,11 @@ export function SnippetsPanel({
{(!snippetSearch || uncategorizedSnippets.length > 0) && (
<div className="flex flex-col gap-2">
<button
onClick={() => setUncategorizedOpen((v) => !v)}
onClick={() => toggleOpenFolder(UNCATEGORIZED_KEY)}
className="flex items-center gap-1.5 w-full text-left"
>
<ChevronDown
className={`size-3 text-muted-foreground shrink-0 transition-transform ${uncategorizedOpen ? "" : "-rotate-90"}`}
className={`size-3 text-muted-foreground shrink-0 transition-transform ${openFolders.has(UNCATEGORIZED_KEY) ? "" : "-rotate-90"}`}
/>
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
<span className="text-xs font-semibold flex-1 truncate text-muted-foreground">
@@ -1871,7 +2146,7 @@ export function SnippetsPanel({
{uncategorizedSnippets.length}
</span>
</button>
{uncategorizedOpen && (
{openFolders.has(UNCATEGORIZED_KEY) && (
<div
className="flex flex-col gap-2 ml-1"
onDrop={() => handleDrop(null)}
@@ -1887,7 +2162,7 @@ export function SnippetsPanel({
onDelete={handleDeleteSnippet}
onEdit={handleEditSnippet}
onShare={setShareSnippet}
onConfirmRun={handleConfirmRun}
onRunSnippet={handleRunSnippet}
onDirectExecute={handleDirectExecute}
onDragStart={() => handleDragStart(snippet)}
onDragEnd={handleDragEnd}
@@ -1916,15 +2191,16 @@ export function SnippetsPanel({
(s) => s.folder === folder.name,
);
if (folderSnippets.length === 0 && snippetSearch) return null;
const isOpen = openFolders.has(folder.name);
return (
<div key={folder.id} className="flex flex-col gap-2">
<div className="flex items-center gap-1.5 w-full group">
<button
onClick={() => toggleFolder(folder.id)}
onClick={() => toggleOpenFolder(folder.name)}
className="flex items-center gap-1.5 flex-1 min-w-0 text-left"
>
<ChevronDown
className={`size-3 text-muted-foreground shrink-0 transition-transform ${folder.open ? "" : "-rotate-90"}`}
className={`size-3 text-muted-foreground shrink-0 transition-transform ${isOpen ? "" : "-rotate-90"}`}
/>
<FolderIconEl
icon={folder.icon}
@@ -1967,7 +2243,7 @@ export function SnippetsPanel({
</DropdownMenuContent>
</DropdownMenu>
</div>
{folder.open && (
{isOpen && (
<div
className="flex flex-col gap-2 ml-1"
onDragOver={(e) => e.preventDefault()}
@@ -1983,7 +2259,7 @@ export function SnippetsPanel({
onDelete={handleDeleteSnippet}
onEdit={handleEditSnippet}
onShare={setShareSnippet}
onConfirmRun={handleConfirmRun}
onRunSnippet={handleRunSnippet}
onDirectExecute={handleDirectExecute}
onDragStart={() => handleDragStart(snippet)}
onDragEnd={handleDragEnd}
@@ -2024,7 +2300,7 @@ export function SnippetsPanel({
onDelete={handleDeleteSnippet}
onEdit={handleEditSnippet}
onShare={setShareSnippet}
onConfirmRun={handleConfirmRun}
onRunSnippet={handleRunSnippet}
onDirectExecute={handleDirectExecute}
draggedSnippet={draggedSnippet}
dropTarget={dropTarget}
@@ -2034,13 +2310,25 @@ export function SnippetsPanel({
onDrop={handleDrop}
availableHosts={availableHosts}
t={t}
defaultOpen={!getFoldersCollapsed()}
open={openFolders.has(folderName)}
onToggleOpen={() => toggleOpenFolder(folderName)}
/>
);
})}
</div>
</div>
{runSnippetDialog}
{directRunningSnippet && (
<SnippetVariablesDialog
snippet={directRunningSnippet.snippet}
host={directRunningSnippet.host}
onCancel={() => setDirectRunningSnippet(null)}
onConfirm={directRunningSnippet.onConfirm}
/>
)}
<SnippetFormDialog
open={snippetFormOpen}
onOpenChange={(v) => {
+10
View File
@@ -15,6 +15,12 @@ const LAYOUT_PREVIEWS: Record<SplitMode, React.ReactNode> = {
<div className="flex-1 border-2 border-current" />
</div>
),
"2-way-horizontal": (
<div className="flex flex-col gap-0.5 size-full">
<div className="flex-1 border-2 border-current" />
<div className="flex-1 border-2 border-current" />
</div>
),
"3-way": (
<div className="flex gap-0.5 size-full">
<div className="flex-1 border-2 border-current" />
@@ -376,6 +382,10 @@ export function SplitScreenPanel({
keys: ["Ctrl", "Shift", "["],
label: t("newUi.sidebar.splitScreen.hotkeysPrevTab"),
},
{
keys: ["Alt", "1-9"],
label: t("newUi.sidebar.splitScreen.hotkeysJumpTab"),
},
].map(({ keys, label }) => (
<div
key={label}
+12 -25
View File
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../lib/error-message.js";
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -202,7 +203,7 @@ function ClaimHandle({ onCreated }: { onCreated: () => void }) {
toast.success(t("termixId.created"));
onCreated();
} catch (e) {
toast.error(e instanceof Error ? e.message : t("termixId.createFailed"));
toast.error(getErrorMessage(e, t("termixId.createFailed")));
} finally {
setSubmitting(false);
}
@@ -311,7 +312,7 @@ function IdentityCard({
toast.success(t("termixId.deleted"));
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : t("termixId.deleteFailed"));
toast.error(getErrorMessage(e, t("termixId.deleteFailed")));
} finally {
setBusy(false);
setConfirming(false);
@@ -500,7 +501,7 @@ function AddKey({
setLabel("");
onAdded();
} catch (e) {
toast.error(e instanceof Error ? e.message : t("termixId.addKeyFailed"));
toast.error(getErrorMessage(e, t("termixId.addKeyFailed")));
} finally {
setSubmitting(false);
}
@@ -519,9 +520,7 @@ function AddKey({
if (saveToVault) loadCredentials();
onAdded();
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("termixId.generateFailed"),
);
toast.error(getErrorMessage(e, t("termixId.generateFailed")));
} finally {
setGenerating(false);
}
@@ -534,7 +533,7 @@ function AddKey({
toast.success(t("termixId.imported"));
onAdded();
} catch (e) {
toast.error(e instanceof Error ? e.message : t("termixId.importFailed"));
toast.error(getErrorMessage(e, t("termixId.importFailed")));
} finally {
setSubmitting(false);
}
@@ -636,9 +635,7 @@ function KeyList({
await setTermixIdKeyEnabled(k.id, !k.enabled);
onChanged();
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("termixId.updateKeyFailed"),
);
toast.error(getErrorMessage(e, t("termixId.updateKeyFailed")));
}
}
@@ -648,9 +645,7 @@ function KeyList({
toast.success(t("termixId.keyRemoved"));
onChanged();
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("termixId.removeKeyFailed"),
);
toast.error(getErrorMessage(e, t("termixId.removeKeyFailed")));
}
}
@@ -661,9 +656,7 @@ function KeyList({
downloadText(`termix-${handle}-${k.id}-cert.pub`, res.certificate + "\n");
toast.success(t("termixId.certIssued"));
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("termixId.certIssueFailed"),
);
toast.error(getErrorMessage(e, t("termixId.certIssueFailed")));
} finally {
setIssuingId(null);
}
@@ -771,9 +764,7 @@ function CaCard({
toast.success(t("termixId.caEnabled"));
onChanged();
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("termixId.caCreateFailed"),
);
toast.error(getErrorMessage(e, t("termixId.caCreateFailed")));
} finally {
setBusy(false);
}
@@ -786,9 +777,7 @@ function CaCard({
toast.success(t("termixId.caRotated"));
onChanged();
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("termixId.caRotateFailed"),
);
toast.error(getErrorMessage(e, t("termixId.caRotateFailed")));
} finally {
setBusy(false);
setConfirmingRotate(false);
@@ -802,9 +791,7 @@ function CaCard({
toast.success(t("termixId.caDeleted"));
onChanged();
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("termixId.caDeleteFailed"),
);
toast.error(getErrorMessage(e, t("termixId.caDeleteFailed")));
} finally {
setBusy(false);
setConfirmingDelete(false);
+336 -335
View File
@@ -14,12 +14,14 @@ import {
enableTOTP,
disableTOTP,
getVersionInfo,
releaseUrlFrom,
getUserRoles,
saveUserPreferences,
getUserPreferences,
} from "@/main-axios";
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import { useHostSidebarPreferences } from "./tree/hooks/useHostSidebarPreferences";
import {
deleteWebAuthnCredential,
listWebAuthnCredentials,
@@ -35,6 +37,7 @@ import { shouldForceLocalPreferenceStorage } from "@/settings/remote-sync-state"
import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { VersionBadge } from "@/components/version-badge";
import {
Dialog,
DialogContent,
@@ -48,33 +51,27 @@ import {
AlertCircle,
CheckCircle2,
ChevronDown,
Clock,
Copy,
Database,
Eye,
EyeOff,
Fingerprint,
Hammer,
KeyRound,
LayoutPanelLeft,
LayoutTemplate,
Network,
Palette,
Play,
Plug,
Plus,
RotateCcw,
ScrollText,
Server,
Shield,
ShieldCheck,
Trash2,
Type,
Usb,
User,
X,
Zap,
} from "lucide-react";
import { SettingRow, FakeSwitch } from "@/components/section-card";
import { visibleRailItems } from "./rail-items";
import { InterfacePresetSettings } from "./InterfacePresetSettings";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { KeybindingsDialog } from "./KeybindingsDialog";
import {
ACCENT_PRESET_COLORS,
@@ -87,9 +84,17 @@ import { useTheme } from "@/components/theme-provider";
import type { FontSizeId, ThemeId } from "@/types/ui-types";
import { toast } from "sonner";
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
import { ConnectionDefaultsSettings } from "./ConnectionDefaultsSettings";
type UserProfileSection =
"account" | "appearance" | "security" | "api-keys" | "data" | "c2s-tunnels";
| "account"
| "interface"
| "appearance"
| "security"
| "api-keys"
| "data"
| "c2s-tunnels";
const THEMES: { id: ThemeId; preview: string }[] = [
{ id: "system", preview: "auto" },
@@ -207,7 +212,7 @@ type CreatedProfileApiKey = {
expiresAt?: string | null;
};
function NewApiKeyDialog({
export function NewApiKeyDialog({
open,
onOpenChange,
onAdd,
@@ -221,12 +226,23 @@ function NewApiKeyDialog({
const { t } = useTranslation();
const [name, setName] = useState("");
const [expiry, setExpiry] = useState("");
const [createdToken, setCreatedToken] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const close = () => {
setName("");
setExpiry("");
setCreatedToken(null);
onOpenChange(false);
};
const handleCreate = async () => {
if (!name.trim()) {
toast.error(t("newUi.sidebar.userProfile.apiKeyNameRequired"));
return;
}
if (creating) return;
setCreating(true);
try {
const created = await createApiKey(
name.trim(),
@@ -234,17 +250,22 @@ function NewApiKeyDialog({
expiry ? new Date(expiry).toISOString() : undefined,
);
onAdd(created);
onOpenChange(false);
setName("");
setExpiry("");
setCreatedToken(created.token);
toast.success(t("newUi.sidebar.userProfile.apiKeyCreated", { name }));
} catch {
toast.error(t("newUi.sidebar.userProfile.apiKeyCreateFailed"));
} finally {
setCreating(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen || !createdToken) onOpenChange(nextOpen);
}}
>
<DialogContent className="sm:max-w-md rounded-none border-border bg-card p-0 gap-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-4 border-b border-border">
<div className="flex items-center gap-2.5">
@@ -262,53 +283,93 @@ function NewApiKeyDialog({
</div>
</DialogHeader>
<div className="flex flex-col gap-4 px-5 py-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.apiKeyNameLabel")}
</label>
<Input
autoFocus
placeholder={t("newUi.sidebar.userProfile.apiKeyNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
className="rounded-none bg-muted/50 border-border text-sm h-9"
/>
{createdToken ? (
<div className="flex flex-col gap-3 px-5 py-4">
<span className="text-xs font-semibold text-accent-brand">
{t("newUi.sidebar.userProfile.apiKeyCreatedWarning")}
</span>
<div className="flex items-center gap-2 border border-border bg-muted/30 px-2 py-2">
<code className="min-w-0 flex-1 break-all text-xs text-accent-brand">
{createdToken}
</code>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={t("newUi.sidebar.userProfile.copyApiKey")}
onClick={() => {
copyToClipboard(createdToken);
toast.info(t("newUi.sidebar.userProfile.copiedToClipboard"));
}}
>
<Copy className="size-3.5" />
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4 px-5 py-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.apiKeyNameLabel")}
</label>
<Input
autoFocus
placeholder={t(
"newUi.sidebar.userProfile.apiKeyNamePlaceholder",
)}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
className="rounded-none bg-muted/50 border-border text-sm h-9"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.expiryDateLabel")}{" "}
<span className="text-muted-foreground/50 normal-case font-medium">
({t("newUi.sidebar.userProfile.optional")})
</span>
</label>
<Input
type="date"
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
className="rounded-none bg-muted/50 border-border text-sm h-9"
/>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.expiryDateLabel")}{" "}
<span className="text-muted-foreground/50 normal-case font-medium">
({t("newUi.sidebar.userProfile.optional")})
</span>
</label>
<Input
type="date"
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
className="rounded-none bg-muted/50 border-border text-sm h-9"
/>
</div>
</div>
</div>
)}
<DialogFooter className="px-5 py-3 border-t border-border bg-muted/20">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("newUi.sidebar.userProfile.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest gap-1.5"
onClick={handleCreate}
>
<KeyRound className="size-3" />{" "}
{t("newUi.sidebar.userProfile.createKey")}
</Button>
{createdToken ? (
<Button
variant="outline"
className="rounded-none border-accent-brand/40 text-[10px] font-bold uppercase tracking-widest text-accent-brand"
onClick={close}
>
{t("newUi.sidebar.userProfile.done")}
</Button>
) : (
<>
<Button
variant="ghost"
onClick={close}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("newUi.sidebar.userProfile.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest gap-1.5"
onClick={handleCreate}
disabled={creating}
>
<KeyRound className="size-3" />{" "}
{t("newUi.sidebar.userProfile.createKey")}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
@@ -441,6 +502,8 @@ export function UserProfilePanel({
storageMode?: string | null;
commandAutocomplete?: boolean | null;
commandPaletteEnabled?: boolean | null;
aiAssistantEnabled?: boolean | null;
aiReadOnlyCommands?: boolean | null;
showHostTags?: boolean | null;
hostTrayOnClick?: boolean | null;
compactHostView?: boolean | null;
@@ -485,6 +548,7 @@ export function UserProfilePanel({
const [versionStatus, setVersionStatus] = useState<
"up_to_date" | "requires_update" | "beta"
>("up_to_date");
const [releaseUrl, setReleaseUrl] = useState("");
const [isOidc, setIsOidc] = useState(false);
const [isDualAuth, setIsDualAuth] = useState(false);
@@ -604,29 +668,72 @@ export function UserProfilePanel({
return v !== null ? v === "true" : true;
});
const [keybindingsDialogOpen, setKeybindingsDialogOpen] = useState(false);
const [showHostTags, setShowHostTags] = useState(() => {
const v = localStorage.getItem("showHostTags");
return v !== null ? v === "true" : true;
});
const [hostTrayOnClick, setHostTrayOnClick] = useState(
() => localStorage.getItem("hostTrayOnClick") !== "false",
);
const [compactHostView, setCompactHostView] = useState(
() => localStorage.getItem("compactHostView") === "true",
);
const [statusColorScheme, setStatusColorScheme] = useState(
() => localStorage.getItem("statusColorScheme") ?? "accent",
const [aiAssistantEnabled, setAiAssistantEnabled] = useState(false);
const [aiReadOnlyCommands, setAiReadOnlyCommands] = useState(false);
// Null until the status call answers; the whole section stays hidden while
// unknown so a user who cannot have the feature never sees it mentioned.
const [aiGloballyEnabled, setAiGloballyEnabled] = useState<boolean | null>(
null,
);
// Sidebar display customization (density, tags, tray trigger, status
// colors) now lives in the dedicated Customize Sidebar panel opened from
// the Hosts toolbar, not here -- resetToDefaults still needs write access.
const { update: updateSidebarPrefs } = useHostSidebarPreferences();
const uiPrefs = useUiPreferencesContext();
useEffect(() => {
let cancelled = false;
import("@/api/ai-api")
.then(({ getAiStatus }) => getAiStatus())
.then((status) => {
if (cancelled) return;
setAiGloballyEnabled(status.globallyEnabled);
setAiAssistantEnabled(status.enabled);
setAiReadOnlyCommands(status.allowReadOnlyCommands);
})
.catch(() => {
if (!cancelled) setAiGloballyEnabled(false);
});
return () => {
cancelled = true;
};
}, []);
/**
* Turning the assistant off also hides its rail tab, so declining removes
* the feature from view entirely rather than leaving an inert entry behind.
*/
const applyAiEnabled = (enabled: boolean) => {
setAiAssistantEnabled(enabled);
const hidden = new Set<string>(
JSON.parse(localStorage.getItem("hiddenRailTabs") ?? "[]"),
);
if (enabled) hidden.delete("ai");
else hidden.add("ai");
const serialized = JSON.stringify([...hidden]);
localStorage.setItem("hiddenRailTabs", serialized);
setHiddenRailTabs(hidden);
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
if (storageMode === "cloud") {
saveToCloud({ aiAssistantEnabled: enabled, hiddenRailTabs: serialized });
}
};
const [pinAppRail, setPinAppRail] = useState(() =>
readRailPreference("pinAppRail"),
);
const [expandAppRailOnHover, setExpandAppRailOnHover] = useState(() =>
readRailPreference("expandAppRailOnHover"),
);
const [foldersCollapsed, setFoldersCollapsed] = useState(
// Read values are unused now that the Snippets settings UI lives in
// SnippetsPanel.tsx; the setters still back the cloud-sync/reset/snapshot
// machinery for these two localStorage-backed prefs below.
const [_foldersCollapsed, setFoldersCollapsed] = useState(
() => localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
);
const [confirmSnippetExecution, setConfirmSnippetExecution] = useState(
const [_confirmSnippetExecution, setConfirmSnippetExecution] = useState(
() => localStorage.getItem("confirmSnippetExecution") === "true",
);
const [disableUpdateCheck, setDisableUpdateCheck] = useState(
@@ -692,6 +799,7 @@ export function UserProfilePanel({
.then((info) => {
setVersion(info.localVersion);
setVersionStatus(info.status ?? "up_to_date");
setReleaseUrl(releaseUrlFrom(info));
})
.catch(() => {});
}, [t]);
@@ -737,6 +845,13 @@ export function UserProfilePanel({
"confirmTabClose",
"hiddenRailTabs",
"statusColorScheme",
"uiPreferences",
// Layout/view keys that were never snapshotted, so switching to cloud
// and back silently reset them.
"dashboardTab.slots",
"dashboardTab.mainWidthPct",
"termix-terminal-toolbar-density",
"fileManagerViewMode",
];
const snap: Record<string, string | null> = { __theme: theme };
for (const key of SNAPSHOT_KEYS) snap[key] = localStorage.getItem(key);
@@ -774,26 +889,15 @@ export function UserProfilePanel({
String(prefs.commandPaletteEnabled),
);
}
if (prefs.showHostTags != null) {
setShowHostTags(prefs.showHostTags);
localStorage.setItem("showHostTags", String(prefs.showHostTags));
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
if (prefs.aiAssistantEnabled != null) {
setAiAssistantEnabled(prefs.aiAssistantEnabled);
}
if (prefs.hostTrayOnClick != null) {
setHostTrayOnClick(prefs.hostTrayOnClick);
localStorage.setItem(
"hostTrayOnClick",
String(prefs.hostTrayOnClick),
);
}
if (prefs.compactHostView != null) {
setCompactHostView(prefs.compactHostView);
localStorage.setItem(
"compactHostView",
String(prefs.compactHostView),
);
window.dispatchEvent(new CustomEvent("compactHostViewChanged"));
if (prefs.aiReadOnlyCommands != null) {
setAiReadOnlyCommands(prefs.aiReadOnlyCommands);
}
// showHostTags/hostTrayOnClick/compactHostView/statusColorScheme are
// no longer restored here -- useHostSidebarPreferences independently
// fetches its own authoritative copy from /host-sidebar/preferences.
if (prefs.pinAppRail != null) {
setPinAppRail(prefs.pinAppRail);
localStorage.setItem("pinAppRail", String(prefs.pinAppRail));
@@ -841,11 +945,6 @@ export function UserProfilePanel({
localStorage.setItem("hiddenRailTabs", prefs.hiddenRailTabs);
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
}
if (prefs.statusColorScheme != null) {
setStatusColorScheme(prefs.statusColorScheme);
localStorage.setItem("statusColorScheme", prefs.statusColorScheme);
window.dispatchEvent(new CustomEvent("statusColorSchemeChanged"));
}
} catch {
// leave UI as-is on error
}
@@ -857,6 +956,7 @@ export function UserProfilePanel({
}
function resetToDefaults() {
clearLocalAdaptivePreferences();
const DEFAULT_ACCENT = "#f59145";
setTheme("system");
setFontSize("md");
@@ -871,14 +971,16 @@ export function UserProfilePanel({
localStorage.setItem("commandAutocomplete", "false");
setCommandPaletteEnabled(true);
localStorage.setItem("commandPaletteShortcutEnabled", "true");
setShowHostTags(true);
localStorage.setItem("showHostTags", "true");
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
setHostTrayOnClick(true);
localStorage.setItem("hostTrayOnClick", "true");
setCompactHostView(false);
localStorage.setItem("compactHostView", "false");
window.dispatchEvent(new CustomEvent("compactHostViewChanged"));
updateSidebarPrefs((prev) => ({
...prev,
display: {
...prev.display,
showTags: true,
trayTrigger: "hover",
density: "comfortable",
statusColorScheme: "accent",
},
}));
setPinAppRail(false);
localStorage.setItem("pinAppRail", "false");
window.dispatchEvent(new Event("pinAppRailChanged"));
@@ -896,9 +998,9 @@ export function UserProfilePanel({
setHiddenRailTabs(new Set());
localStorage.removeItem("hiddenRailTabs");
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
setStatusColorScheme("accent");
localStorage.setItem("statusColorScheme", "accent");
window.dispatchEvent(new CustomEvent("statusColorSchemeChanged"));
// Back to the preset that matches the pre-preset UI, with nothing pinned.
uiPrefs?.setPreset("balanced");
uiPrefs?.clearAllOverrides();
if (storageMode === "cloud") {
saveToCloud({
theme: "system",
@@ -907,9 +1009,6 @@ export function UserProfilePanel({
language: "en",
commandAutocomplete: false,
commandPaletteEnabled: true,
showHostTags: true,
hostTrayOnClick: true,
compactHostView: false,
pinAppRail: false,
expandAppRailOnHover: true,
foldersCollapsed: true,
@@ -917,7 +1016,6 @@ export function UserProfilePanel({
disableUpdateCheck: false,
confirmTabClose: false,
hiddenRailTabs: "[]",
statusColorScheme: "accent",
});
}
localSnapshot.current = {};
@@ -972,20 +1070,9 @@ export function UserProfilePanel({
String(restoredPalette),
);
const restoredHostTags = restore("showHostTags", "true") !== "false";
setShowHostTags(restoredHostTags);
localStorage.setItem("showHostTags", String(restoredHostTags));
window.dispatchEvent(new CustomEvent("showHostTagsChanged"));
const restoredTrayOnClick = restore("hostTrayOnClick", "true") !== "false";
setHostTrayOnClick(restoredTrayOnClick);
localStorage.setItem("hostTrayOnClick", String(restoredTrayOnClick));
const restoredCompactHostView =
restore("compactHostView", "false") === "true";
setCompactHostView(restoredCompactHostView);
localStorage.setItem("compactHostView", String(restoredCompactHostView));
window.dispatchEvent(new CustomEvent("compactHostViewChanged"));
// showHostTags/hostTrayOnClick/compactHostView/statusColorScheme are no
// longer part of this snapshot -- useHostSidebarPreferences keeps its own
// localStorage cache independent of storageMode switches.
const restoredPinRail = restore("pinAppRail", "false") === "true";
setPinAppRail(restoredPinRail);
@@ -1046,12 +1133,6 @@ export function UserProfilePanel({
}
window.dispatchEvent(new CustomEvent("hiddenRailTabsChanged"));
const restoredStatusScheme =
restore("statusColorScheme", "accent") ?? "accent";
setStatusColorScheme(restoredStatusScheme);
localStorage.setItem("statusColorScheme", restoredStatusScheme);
window.dispatchEvent(new CustomEvent("statusColorSchemeChanged"));
localStorage.removeItem("termix-local-snapshot");
localSnapshot.current = {};
}
@@ -1330,7 +1411,7 @@ export function UserProfilePanel({
<NewApiKeyDialog
open={newKeyOpen}
onOpenChange={setNewKeyOpen}
onAdd={(key) => setApiKeys((prev) => [key, ...prev])}
onAdd={(key) => setApiKeys((prev) => [key as ApiKey, ...prev])}
userId={userId}
/>
@@ -1483,21 +1564,7 @@ export function UserProfilePanel({
<span className="text-sm font-bold text-accent-brand">
{version ? `v${version}` : "—"}
</span>
<span
className={`text-[10px] px-1.5 py-0.5 font-semibold leading-none ${
versionStatus === "beta"
? "bg-blue-500/20 text-blue-400"
: versionStatus === "requires_update"
? "bg-yellow-500/20 text-yellow-400"
: "bg-accent-brand/20 text-accent-brand"
}`}
>
{versionStatus === "beta"
? t("dashboard.beta").toUpperCase()
: versionStatus === "requires_update"
? t("dashboard.updateAvailable").toUpperCase()
: t("dashboardTab.stable")}
</span>
<VersionBadge status={versionStatus} releaseUrl={releaseUrl} />
</div>
</div>
@@ -1530,6 +1597,27 @@ export function UserProfilePanel({
</div>
</div>
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t("newUi.sidebar.userProfile.cliTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.cliDescription")}
</span>
</div>
<a
href="https://docs.termix.site/cli"
target="_blank"
rel="noopener noreferrer"
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
>
{t("newUi.sidebar.userProfile.cliLearnMore")}
</a>
</div>
</div>
{isElectron() && (
<div className="border-t border-border pt-3 mt-3">
<RemoteSyncPanel initialServerUrl={remoteSyncInitialServerUrl} />
@@ -1561,6 +1649,23 @@ export function UserProfilePanel({
</div>
</AccordionSection>
{/* Interface */}
<AccordionSection
id="interface"
label={t("newUi.sidebar.userProfile.sectionInterface")}
icon={<LayoutTemplate size={13} />}
open={openSections.has("interface")}
onToggle={() => toggle("interface")}
>
<div className="flex flex-col gap-4 pt-3">
<InterfacePresetSettings
onRunSetupAgain={() =>
window.dispatchEvent(new Event("termix:open-onboarding"))
}
/>
</div>
</AccordionSection>
{/* Appearance */}
<AccordionSection
id="appearance"
@@ -1711,6 +1816,7 @@ export function UserProfilePanel({
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
{t("newUi.sidebar.userProfile.settingsTerminal")}
</span>
<ConnectionDefaultsSettings />
<SettingRow
label={t("newUi.sidebar.userProfile.commandAutocomplete")}
description={t(
@@ -1727,6 +1833,29 @@ export function UserProfilePanel({
}}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("newUi.sidebar.userProfile.localEcho")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("newUi.sidebar.userProfile.localEchoDesc")}
</span>
</div>
<select
defaultValue={
localStorage.getItem("terminalLocalEchoMode") ?? "auto"
}
onChange={(e) =>
localStorage.setItem("terminalLocalEchoMode", e.target.value)
}
className="h-7 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="off">{t("hosts.localEchoOff")}</option>
<option value="auto">{t("hosts.localEchoAuto")}</option>
<option value="on">{t("hosts.localEchoOn")}</option>
</select>
</div>
<SettingRow
label={t("newUi.sidebar.userProfile.keyboardShortcuts")}
description={t(
@@ -1789,6 +1918,34 @@ export function UserProfilePanel({
}}
/>
</SettingRow>
{aiGloballyEnabled && (
<>
<SettingRow
label={t("ai.enableTitle")}
description={t("ai.enableDescription")}
>
<FakeSwitch
checked={aiAssistantEnabled}
onChange={applyAiEnabled}
/>
</SettingRow>
{aiAssistantEnabled && (
<SettingRow
label={t("ai.readOnlyCommands")}
description={t("ai.readOnlyCommandsDescription")}
>
<FakeSwitch
checked={aiReadOnlyCommands}
onChange={(v) => {
setAiReadOnlyCommands(v);
if (storageMode === "cloud")
saveToCloud({ aiReadOnlyCommands: v });
}}
/>
</SettingRow>
)}
</>
)}
<SettingRow
label={t("newUi.sidebar.userProfile.reopenTabsOnLogin")}
description={t("newUi.sidebar.userProfile.reopenTabsOnLoginDesc")}
@@ -1823,68 +1980,11 @@ export function UserProfilePanel({
<div className="flex flex-col gap-1 border-t border-border pt-3">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
{t("newUi.sidebar.userProfile.settingsSidebar")}
{t("newUi.sidebar.userProfile.settingsAppRail")}
</span>
<SettingRow
label={t("newUi.sidebar.userProfile.showHostTags")}
description={t("newUi.sidebar.userProfile.showHostTagsDesc")}
>
<FakeSwitch
checked={showHostTags}
onChange={(v) => {
setShowHostTags(v);
localStorage.setItem("showHostTags", v.toString());
window.dispatchEvent(new Event("showHostTagsChanged"));
if (storageMode === "cloud") saveToCloud({ showHostTags: v });
}}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.hostTrayOnClick")}
description={t("newUi.sidebar.userProfile.hostTrayOnClickDesc")}
>
<FakeSwitch
checked={hostTrayOnClick}
onChange={(v) => {
setHostTrayOnClick(v);
localStorage.setItem("hostTrayOnClick", v.toString());
window.dispatchEvent(new Event("hostTrayOnClickChanged"));
if (storageMode === "cloud")
saveToCloud({ hostTrayOnClick: v });
}}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.compactHostView")}
description={t("newUi.sidebar.userProfile.compactHostViewDesc")}
>
<FakeSwitch
checked={compactHostView}
onChange={(v) => {
setCompactHostView(v);
localStorage.setItem("compactHostView", v.toString());
window.dispatchEvent(new Event("compactHostViewChanged"));
if (storageMode === "cloud")
saveToCloud({ compactHostView: v });
}}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.statusColors")}
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
>
<FakeSwitch
checked={statusColorScheme === "status"}
onChange={(v) => {
const scheme = v ? "status" : "accent";
setStatusColorScheme(scheme);
localStorage.setItem("statusColorScheme", scheme);
window.dispatchEvent(new Event("statusColorSchemeChanged"));
if (storageMode === "cloud")
saveToCloud({ statusColorScheme: scheme });
}}
/>
</SettingRow>
<p className="text-[10px] text-muted-foreground mb-2">
{t("newUi.sidebar.userProfile.sidebarSettingsMoved")}
</p>
<SettingRow
label={t("newUi.sidebar.userProfile.pinAppRail")}
description={t("newUi.sidebar.userProfile.pinAppRailDesc")}
@@ -1920,134 +2020,35 @@ export function UserProfilePanel({
<p className="text-[10px] text-muted-foreground mb-2">
{t("newUi.sidebar.userProfile.navigationTabsDesc")}
</p>
{(
[
{
id: "hosts",
icon: <Server size={12} />,
label: t("nav.hosts"),
},
{
id: "credentials",
icon: <KeyRound size={12} />,
label: t("nav.credentials"),
},
{
id: "termix-id",
icon: <Fingerprint size={12} />,
label: t("nav.termixId"),
},
{
id: "connections",
icon: <Plug size={12} />,
label: t("nav.connections"),
},
{
id: "quick-connect",
icon: <Zap size={12} />,
label: t("nav.quickConnect"),
},
{
id: "serial",
icon: <Usb size={12} />,
label: t("nav.serial"),
},
{
id: "ssh-tools",
icon: <Hammer size={12} />,
label: t("nav.sshTools"),
},
{
id: "snippets",
icon: <Play size={12} />,
label: t("nav.snippets"),
},
{
id: "history",
icon: <Clock size={12} />,
label: t("nav.history"),
},
{
id: "session-logs",
icon: <ScrollText size={12} />,
label: t("nav.sessionLogs"),
},
{
id: "split-screen",
icon: <LayoutPanelLeft size={12} />,
label: t("nav.splitScreen"),
},
{
id: "network_graph",
icon: <Network size={12} />,
label: t("nav.networkGraph"),
},
] as { id: string; icon: React.ReactNode; label: string }[]
).map((tab) => (
<div
key={tab.id}
className="flex items-center justify-between py-1.5"
>
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
<span className="text-muted-foreground">{tab.icon}</span>
{tab.label}
</span>
<FakeSwitch
checked={!hiddenRailTabs.has(tab.id)}
onChange={(visible) => {
const next = new Set(hiddenRailTabs);
if (visible) next.delete(tab.id);
else next.add(tab.id);
setHiddenRailTabs(next);
const serialized = JSON.stringify([...next]);
localStorage.setItem("hiddenRailTabs", serialized);
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
if (storageMode === "cloud")
saveToCloud({ hiddenRailTabs: serialized });
}}
/>
</div>
))}
</div>
<div className="flex flex-col gap-1 border-t border-border pt-3">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
{t("newUi.sidebar.userProfile.settingsSnippets")}
</span>
<SettingRow
label={t("newUi.sidebar.userProfile.foldersCollapsed")}
description={t("newUi.sidebar.userProfile.foldersCollapsedDesc")}
>
<FakeSwitch
checked={foldersCollapsed}
onChange={(v) => {
setFoldersCollapsed(v);
localStorage.setItem(
"defaultSnippetFoldersCollapsed",
v.toString(),
);
window.dispatchEvent(
new Event("defaultSnippetFoldersCollapsedChanged"),
);
if (storageMode === "cloud")
saveToCloud({ foldersCollapsed: v });
}}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.confirmExecution")}
description={t("newUi.sidebar.userProfile.confirmExecutionDesc")}
>
<FakeSwitch
checked={confirmSnippetExecution}
onChange={(v) => {
setConfirmSnippetExecution(v);
localStorage.setItem("confirmSnippetExecution", v.toString());
if (storageMode === "cloud")
saveToCloud({ confirmSnippetExecution: v });
}}
/>
</SettingRow>
{visibleRailItems()
.filter((tab) => tab.id !== "ai" || aiGloballyEnabled)
.map((tab) => (
<div
key={tab.id}
className="flex items-center justify-between py-1.5"
>
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
<span className="text-muted-foreground">
<tab.icon size={12} />
</span>
{t(tab.labelKey)}
</span>
<FakeSwitch
checked={!hiddenRailTabs.has(tab.id)}
onChange={(visible) => {
const next = new Set(hiddenRailTabs);
if (visible) next.delete(tab.id);
else next.add(tab.id);
setHiddenRailTabs(next);
const serialized = JSON.stringify([...next]);
localStorage.setItem("hiddenRailTabs", serialized);
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
if (storageMode === "cloud")
saveToCloud({ hiddenRailTabs: serialized });
}}
/>
</div>
))}
</div>
<div className="flex flex-col gap-1 border-t border-border pt-3">
+3 -6
View File
@@ -1,3 +1,4 @@
import { getErrorMessage } from "../lib/error-message.js";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Pencil, Plus, Trash2, X } from "lucide-react";
@@ -90,9 +91,7 @@ export function VaultProfileManager({
setForm(null);
onChanged();
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("hosts.vaultProfileSaveFailed"),
);
toast.error(getErrorMessage(e, t("hosts.vaultProfileSaveFailed")));
} finally {
setSaving(false);
}
@@ -104,9 +103,7 @@ export function VaultProfileManager({
toast.success(t("hosts.vaultProfileDeleted"));
onChanged();
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("hosts.vaultProfileDeleteFailed"),
);
toast.error(getErrorMessage(e, t("hosts.vaultProfileDeleteFailed")));
}
};
+650
View File
@@ -0,0 +1,650 @@
import { getErrorMessage } from "../lib/error-message.js";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
LayoutTemplate,
Loader2,
Plus,
Save,
Settings2,
Star,
Trash2,
X,
} from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Badge } from "@/components/badge";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/dialog";
import { FOLDER_COLORS } from "@/lib/theme";
import {
listWorkspaces,
createWorkspace,
renameWorkspace,
updateWorkspaceContent,
deleteWorkspace,
duplicateWorkspace,
setDefaultWorkspace,
unsetDefaultWorkspace,
} from "@/api/workspaces-api";
import type { Workspace, WorkspacePayload } from "@/types/ui-types";
function timeAgo(isoStr: string): string {
const ms = Date.now() - new Date(isoStr).getTime();
if (!Number.isFinite(ms)) return "";
const sec = Math.floor(ms / 1000);
if (sec < 60) return `${sec}s ago`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m ago`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h ago`;
return `${Math.floor(hr / 24)}d ago`;
}
function WorkspaceSaveDialog({
open,
onClose,
onSave,
}: {
open: boolean;
onClose: () => void;
onSave: (name: string, color: string) => Promise<void>;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [color, setColor] = useState<string>(FOLDER_COLORS[0]);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setName("");
setColor(FOLDER_COLORS[0]);
}, [open]);
async function handleSave() {
if (!name.trim()) {
toast.error(t("newUi.sidebar.workspaces.nameRequired"));
return;
}
setSaving(true);
try {
await onSave(name.trim(), color);
onClose();
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t("newUi.sidebar.workspaces.saveCurrentTitle")}
</DialogTitle>
<DialogDescription>
{t("newUi.sidebar.workspaces.saveCurrentDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">
{t("newUi.sidebar.workspaces.nameLabel")}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("newUi.sidebar.workspaces.namePlaceholder")}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
}}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">
{t("newUi.sidebar.workspaces.colorLabel")}
</label>
<div className="flex gap-1.5">
{FOLDER_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`size-6 transition-all ${
color === c
? "ring-2 ring-offset-2 ring-offset-background ring-white/50"
: "opacity-75 hover:opacity-100"
}`}
style={{ backgroundColor: c }}
aria-label={c}
/>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
disabled={saving}
>
{saving && <Loader2 className="size-3.5 mr-2 animate-spin" />}
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function WorkspaceRenameDialog({
workspace,
onClose,
onSaved,
}: {
workspace: Workspace | null;
onClose: () => void;
onSaved: () => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [color, setColor] = useState<string>(FOLDER_COLORS[0]);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!workspace) return;
setName(workspace.name);
setColor(workspace.color ?? FOLDER_COLORS[0]);
}, [workspace]);
async function handleSave() {
if (!workspace) return;
if (!name.trim()) {
toast.error(t("newUi.sidebar.workspaces.nameRequired"));
return;
}
setSaving(true);
try {
await renameWorkspace(workspace.id, { name: name.trim(), color });
toast.success(t("newUi.sidebar.workspaces.workspaceUpdated"));
onSaved();
onClose();
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.workspaces.updateFailed"));
} finally {
setSaving(false);
}
}
return (
<Dialog open={!!workspace} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("newUi.sidebar.workspaces.rename")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">
{t("newUi.sidebar.workspaces.nameLabel")}
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
}}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">
{t("newUi.sidebar.workspaces.colorLabel")}
</label>
<div className="flex gap-1.5">
{FOLDER_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`size-6 transition-all ${
color === c
? "ring-2 ring-offset-2 ring-offset-background ring-white/50"
: "opacity-75 hover:opacity-100"
}`}
style={{ backgroundColor: c }}
aria-label={c}
/>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
disabled={saving}
>
{saving && <Loader2 className="size-3.5 mr-2 animate-spin" />}
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function WorkspaceRow({
workspace,
isLastSession,
onApply,
onUpdateWithCurrent,
onRename,
onDuplicate,
onDelete,
onSetDefault,
}: {
workspace: Workspace;
isLastSession: boolean;
onApply: () => void;
onUpdateWithCurrent: () => void;
onRename: () => void;
onDuplicate: () => void;
onDelete: () => void;
onSetDefault: () => void;
}) {
const { t } = useTranslation();
return (
<div
className="flex flex-col gap-1 border border-border p-2.5 cursor-pointer hover:bg-muted/40 group"
onClick={onApply}
>
<div className="flex items-center gap-1.5 min-w-0">
<span
className="size-2.5 shrink-0"
style={{ backgroundColor: workspace.color ?? "#6b7280" }}
/>
<span
className={`text-xs font-semibold truncate min-w-0 ${isLastSession ? "italic text-muted-foreground" : ""}`}
>
{isLastSession
? t("newUi.sidebar.workspaces.lastSession")
: workspace.name}
</span>
{workspace.isDefault && (
<Star className="size-3 shrink-0 fill-accent-brand text-accent-brand" />
)}
<Badge variant="secondary" className="shrink-0 ml-auto">
{t("newUi.sidebar.workspaces.tabCount", {
count: workspace.tabCount,
})}
</Badge>
</div>
<span className="text-[11px] text-muted-foreground truncate pl-4">
{isLastSession
? t("newUi.sidebar.workspaces.lastSessionDescription")
: workspace.lastUsedAt
? t("newUi.sidebar.workspaces.lastUsed", {
time: timeAgo(workspace.lastUsedAt),
})
: t("newUi.sidebar.workspaces.neverUsed")}
</span>
{!isLastSession && (
<div className="flex items-center justify-end gap-0.5 opacity-0 group-hover:opacity-100">
<Button
variant="ghost"
size="icon-sm"
title={
workspace.isDefault
? t("newUi.sidebar.workspaces.unsetDefault")
: t("newUi.sidebar.workspaces.setDefault")
}
onClick={(e) => {
e.stopPropagation();
onSetDefault();
}}
>
<Star
className={`size-3.5 ${workspace.isDefault ? "fill-accent-brand text-accent-brand" : ""}`}
/>
</Button>
<Button
variant="ghost"
size="icon-sm"
title={t("newUi.sidebar.workspaces.updateWithCurrent")}
onClick={(e) => {
e.stopPropagation();
onUpdateWithCurrent();
}}
>
<Save className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
title={t("newUi.sidebar.workspaces.rename")}
onClick={(e) => {
e.stopPropagation();
onRename();
}}
>
<Settings2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
title={t("newUi.sidebar.workspaces.duplicate")}
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
>
<Plus className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
className="hover:text-destructive"
title={t("newUi.sidebar.workspaces.deleteWorkspaceTitle")}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="size-3.5" />
</Button>
</div>
)}
</div>
);
}
export function WorkspacesPanel({
active,
currentPayload,
onApplyWorkspace,
}: {
active?: boolean;
currentPayload: () => WorkspacePayload;
onApplyWorkspace: (workspace: Workspace) => void;
}) {
const { t } = useTranslation();
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
const [loading, setLoading] = useState(true);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<Workspace | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Workspace | null>(null);
const [applyTarget, setApplyTarget] = useState<Workspace | null>(null);
const hasLoadedRef = useRef(false);
const loadWorkspaces = useCallback(async () => {
try {
const data = await listWorkspaces();
setWorkspaces(data);
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.workspaces.loadFailed"));
}
}, [t]);
useEffect(() => {
if (!active || hasLoadedRef.current) return;
hasLoadedRef.current = true;
setLoading(true);
loadWorkspaces().finally(() => setLoading(false));
}, [active, loadWorkspaces]);
async function handleSaveNew(name: string, color: string) {
try {
await createWorkspace({ name, color, payload: currentPayload() });
toast.success(t("newUi.sidebar.workspaces.workspaceCreated"));
loadWorkspaces();
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.workspaces.saveFailed"));
throw error;
}
}
async function handleUpdateWithCurrent(workspace: Workspace) {
try {
await updateWorkspaceContent(workspace.id, currentPayload());
toast.success(t("newUi.sidebar.workspaces.workspaceUpdated"));
loadWorkspaces();
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.workspaces.updateFailed"));
}
}
async function handleDuplicate(workspace: Workspace) {
try {
await duplicateWorkspace(
workspace.id,
t("newUi.sidebar.workspaces.duplicateNameSuffix", {
name: workspace.name,
}),
);
toast.success(t("newUi.sidebar.workspaces.workspaceDuplicated"));
loadWorkspaces();
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.workspaces.duplicateFailed"));
}
}
async function handleToggleDefault(workspace: Workspace) {
try {
if (workspace.isDefault) {
await unsetDefaultWorkspace(workspace.id);
} else {
await setDefaultWorkspace(workspace.id);
}
loadWorkspaces();
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.workspaces.updateFailed"));
}
}
async function handleDelete() {
if (!deleteTarget) return;
try {
await deleteWorkspace(deleteTarget.id);
toast.success(t("newUi.sidebar.workspaces.workspaceDeleted"));
setDeleteTarget(null);
loadWorkspaces();
} catch (error) {
const message = getErrorMessage(error, "");
toast.error(message || t("newUi.sidebar.workspaces.deleteFailed"));
}
}
function handleApplyClick(workspace: Workspace) {
setApplyTarget(workspace);
}
function confirmApply() {
if (!applyTarget) return;
onApplyWorkspace(applyTarget);
setApplyTarget(null);
}
const manualWorkspaces = workspaces.filter((w) => w.kind === "manual");
const lastSession = workspaces.find((w) => w.kind === "last_session");
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex items-center gap-2 p-3 border-b border-border shrink-0">
<LayoutTemplate className="size-4 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-semibold">
{t("newUi.sidebar.workspaces.title")}
</span>
<a
href="https://docs.termix.site/features/workspaces"
target="_blank"
rel="noreferrer"
className="w-fit text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => setSaveDialogOpen(true)}
>
<Plus className="size-3.5 mr-1.5" />
{t("newUi.sidebar.workspaces.saveCurrent")}
</Button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto p-3">
{loading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
) : manualWorkspaces.length === 0 && !lastSession ? (
<div className="text-xs text-muted-foreground text-center py-8">
{t("newUi.sidebar.workspaces.noWorkspaces")}
</div>
) : (
<div className="flex flex-col gap-1.5">
{lastSession && (
<WorkspaceRow
key={lastSession.id}
workspace={lastSession}
isLastSession
onApply={() => handleApplyClick(lastSession)}
onUpdateWithCurrent={() => {}}
onRename={() => {}}
onDuplicate={() => {}}
onDelete={() => {}}
onSetDefault={() => {}}
/>
)}
{manualWorkspaces.map((workspace) => (
<WorkspaceRow
key={workspace.id}
workspace={workspace}
isLastSession={false}
onApply={() => handleApplyClick(workspace)}
onUpdateWithCurrent={() => {
if (
window.confirm(
t("newUi.sidebar.workspaces.updateWithCurrentConfirm", {
name: workspace.name,
}),
)
) {
handleUpdateWithCurrent(workspace);
}
}}
onRename={() => setRenameTarget(workspace)}
onDuplicate={() => handleDuplicate(workspace)}
onDelete={() => setDeleteTarget(workspace)}
onSetDefault={() => handleToggleDefault(workspace)}
/>
))}
</div>
)}
</div>
<WorkspaceSaveDialog
open={saveDialogOpen}
onClose={() => setSaveDialogOpen(false)}
onSave={handleSaveNew}
/>
<WorkspaceRenameDialog
workspace={renameTarget}
onClose={() => setRenameTarget(null)}
onSaved={loadWorkspaces}
/>
<Dialog
open={!!deleteTarget}
onOpenChange={(next) => !next && setDeleteTarget(null)}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>
{t("newUi.sidebar.workspaces.deleteWorkspaceTitle")}
</DialogTitle>
<DialogDescription>
{t("newUi.sidebar.workspaces.deleteWorkspaceDescription", {
name: deleteTarget?.name,
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
{t("common.cancel")}
</Button>
<Button variant="destructive" onClick={handleDelete}>
<X className="size-3.5 mr-2" />
{t("common.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={!!applyTarget}
onOpenChange={(next) => !next && setApplyTarget(null)}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>
{t("newUi.sidebar.workspaces.applyConfirmTitle")}
</DialogTitle>
<DialogDescription>
{t("newUi.sidebar.workspaces.applyConfirmDescription", {
name: applyTarget?.name,
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setApplyTarget(null)}>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={confirmApply}
>
{t("newUi.sidebar.workspaces.applyConfirmButton")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,258 @@
import { getErrorMessage } from "../../lib/error-message.js";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import { Switch } from "@/components/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import {
AUTOMATION_DEFINITION_VERSION,
type AutomationDefinition,
type ConcurrencyPolicy,
type Step,
} from "@/types/automations";
import { StepBlock } from "./StepBlock";
import { TriggerCard, defaultTrigger } from "./TriggerCard";
import { newStepId, type AutomationEditorOptions } from "./editor-types";
export interface AutomationDraft {
name: string;
description: string;
enabled: boolean;
concurrencyPolicy: ConcurrencyPolicy;
definition: AutomationDefinition;
}
export function emptyDraft(): AutomationDraft {
return {
name: "",
description: "",
enabled: true,
concurrencyPolicy: "skip",
definition: {
version: AUTOMATION_DEFINITION_VERSION,
trigger: defaultTrigger("metric_threshold"),
steps: [],
},
};
}
/**
* The automation builder: one trigger, then an ordered list of steps that can
* nest through if/otherwise. A JSON view is available for anything the form
* does not cover yet.
*/
export function AutomationEditor({
draft,
options,
onChange,
}: {
draft: AutomationDraft;
options: AutomationEditorOptions;
onChange: (next: AutomationDraft) => void;
}) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
const [view, setView] = useState<"builder" | "json">("builder");
const [jsonText, setJsonText] = useState("");
const [jsonError, setJsonError] = useState<string | null>(null);
function setDefinition(definition: AutomationDefinition) {
onChange({ ...draft, definition });
}
function setSteps(steps: Step[]) {
setDefinition({ ...draft.definition, steps });
}
function renderSteps(
steps: Step[],
onStepsChange: (next: Step[]) => void,
depth: number,
): React.ReactNode {
return (
<div className="space-y-2">
{steps.map((step, index) => (
<StepBlock
key={step.id}
step={step}
index={index}
total={steps.length}
depth={depth}
options={options}
onChange={(next) => {
const copy = [...steps];
copy[index] = next;
onStepsChange(copy);
}}
onRemove={() => onStepsChange(steps.filter((_, i) => i !== index))}
onMove={(direction) => {
const target = index + direction;
if (target < 0 || target >= steps.length) return;
const copy = [...steps];
[copy[index], copy[target]] = [copy[target], copy[index]];
onStepsChange(copy);
}}
renderNested={renderSteps}
/>
))}
<Button
variant="outline"
size="sm"
className="rounded-none w-full border-border"
onClick={() =>
onStepsChange([
...steps,
{ id: newStepId(), type: "notify", channelIds: [] } as Step,
])
}
>
<Plus size={14} className="mr-1" />
{t(`${base}.addStep`)}
</Button>
</div>
);
}
return (
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.nameLabel`)}
</Label>
<Input
className="h-8 rounded-none"
placeholder={t(`${base}.namePlaceholder`)}
value={draft.name}
onChange={(e) => onChange({ ...draft, name: e.target.value })}
/>
</div>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch
checked={draft.enabled}
onCheckedChange={(enabled) => onChange({ ...draft, enabled })}
/>
{t(`${base}.enabledLabel`)}
</label>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.concurrencyLabel`)}
</Label>
<Select
value={draft.concurrencyPolicy}
onValueChange={(value) =>
onChange({
...draft,
concurrencyPolicy: value as ConcurrencyPolicy,
})
}
>
<SelectTrigger
size="sm"
className="w-full rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">
{t(`${base}.concurrency.skip`)}
</SelectItem>
<SelectItem value="queue">
{t(`${base}.concurrency.queue`)}
</SelectItem>
<SelectItem value="allow">
{t(`${base}.concurrency.allow`)}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t(`${base}.concurrencyHint`)}
</p>
</div>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
className={`rounded-none ${
view === "builder"
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand"
: "border-border"
}`}
onClick={() => setView("builder")}
>
{t(`${base}.viewBuilder`)}
</Button>
<Button
variant="outline"
size="sm"
className={`rounded-none ${
view === "json"
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 hover:text-accent-brand"
: "border-border"
}`}
onClick={() => {
setJsonText(JSON.stringify(draft.definition, null, 2));
setJsonError(null);
setView("json");
}}
>
{t(`${base}.viewJson`)}
</Button>
</div>
{view === "builder" ? (
<>
<TriggerCard
trigger={draft.definition.trigger}
options={options}
onChange={(trigger) =>
setDefinition({ ...draft.definition, trigger })
}
/>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
{t(`${base}.steps`)}
</Label>
{draft.definition.steps.length === 0 && (
<p className="text-xs text-muted-foreground">
{t(`${base}.noSteps`)}
</p>
)}
{renderSteps(draft.definition.steps, setSteps, 0)}
</div>
</>
) : (
<div className="space-y-2">
<textarea
className="w-full min-h-64 bg-background border border-border p-2 font-mono text-xs"
value={jsonText}
onChange={(e) => {
setJsonText(e.target.value);
try {
const parsed = JSON.parse(e.target.value);
setDefinition(parsed as AutomationDefinition);
setJsonError(null);
} catch (error) {
setJsonError(getErrorMessage(error, String(error)));
}
}}
/>
{jsonError && <p className="text-xs text-destructive">{jsonError}</p>}
</div>
)}
</div>
);
}
@@ -0,0 +1,135 @@
import { useTranslation } from "react-i18next";
import { Label } from "@/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import type { HostSelector } from "@/types/automations";
import type { AutomationEditorOptions } from "./editor-types";
/**
* Picks which hosts a step acts on. "The host that triggered this" is the
* common case, so it is offered first and used as the default.
*/
export function HostSelectorField({
value,
options,
onChange,
allowTrigger = true,
}: {
value: HostSelector | undefined;
options: AutomationEditorOptions;
onChange: (next: HostSelector) => void;
allowTrigger?: boolean;
}) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
const kind = value?.kind ?? (allowTrigger ? "trigger" : "host");
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.host`)}
</Label>
<div className="flex gap-2">
<Select
value={kind}
onValueChange={(next) =>
onChange(defaultFor(next as HostSelector["kind"]))
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{allowTrigger && (
<SelectItem value="trigger">
{t(`${base}.hostSelector.trigger`)}
</SelectItem>
)}
<SelectItem value="host">
{t(`${base}.hostSelector.host`)}
</SelectItem>
<SelectItem value="fleet">
{t(`${base}.hostSelector.fleet`)}
</SelectItem>
<SelectItem value="all">{t(`${base}.hostSelector.all`)}</SelectItem>
</SelectContent>
</Select>
{kind === "host" && (
<Select
value={
value?.kind === "host" && value.hostId ? String(value.hostId) : ""
}
onValueChange={(next) =>
onChange({ kind: "host", hostId: Number(next) })
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.hosts.map((host) => (
<SelectItem key={host.id} value={String(host.id)}>
{host.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{kind === "fleet" && (
<Select
value={
value?.kind === "fleet" && value.fleetId
? String(value.fleetId)
: ""
}
onValueChange={(next) =>
onChange({ kind: "fleet", fleetId: Number(next) })
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.fleets.map((fleet) => (
<SelectItem key={fleet.id} value={String(fleet.id)}>
{fleet.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
);
}
function defaultFor(kind: HostSelector["kind"]): HostSelector {
switch (kind) {
case "host":
return { kind: "host", hostId: 0 };
case "fleet":
return { kind: "fleet", fleetId: 0 };
case "hosts":
return { kind: "hosts", hostIds: [] };
case "all":
return { kind: "all" };
default:
return { kind: "trigger" };
}
}
+585
View File
@@ -0,0 +1,585 @@
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp, Trash2 } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import { Textarea } from "@/components/textarea";
import { Switch } from "@/components/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import type { Step, StepType } from "@/types/automations";
import { HostSelectorField } from "./HostSelectorField";
import type { AutomationEditorOptions } from "./editor-types";
const STEP_TYPES: StepType[] = [
"notify",
"http",
"run_command",
"run_snippet",
"docker",
"tunnel",
"wol",
"wait",
"set_var",
"if",
"stop",
];
interface StepBlockProps {
step: Step;
index: number;
total: number;
depth: number;
options: AutomationEditorOptions;
onChange: (next: Step) => void;
onRemove: () => void;
onMove: (direction: -1 | 1) => void;
renderNested: (
steps: Step[],
onChange: (next: Step[]) => void,
depth: number,
) => React.ReactNode;
}
/**
* One block in the automation's step list. Each step type renders only the
* fields it actually uses, so the form stays readable as more types are added.
*/
export function StepBlock({
step,
index,
total,
depth,
options,
onChange,
onRemove,
onMove,
renderNested,
}: StepBlockProps) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
function patch(changes: Partial<Step>) {
onChange({ ...step, ...changes } as Step);
}
return (
<div
className={`border border-border p-3 ${depth > 0 ? "border-l-2 border-l-primary/40" : ""}`}
>
<div className="flex items-center gap-2 mb-3">
<Select
value={step.type}
onValueChange={(value) =>
onChange(changeStepType(step, value as StepType))
}
>
<SelectTrigger
size="sm"
className="flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{STEP_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{t(`${base}.stepTypes.${type}`)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none"
disabled={index === 0}
title={t(`${base}.moveUp`)}
onClick={() => onMove(-1)}
>
<ChevronUp size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none"
disabled={index === total - 1}
title={t(`${base}.moveDown`)}
onClick={() => onMove(1)}
>
<ChevronDown size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-none"
title={t(`${base}.removeStep`)}
onClick={onRemove}
>
<Trash2 size={14} />
</Button>
</div>
<div className="space-y-3">
{step.type === "notify" && (
<>
<Field label={t(`${base}.fields.title`)}>
<Input
className="h-8 rounded-none border-border"
value={step.title ?? ""}
onChange={(e) =>
patch({ title: e.target.value } as Partial<Step>)
}
/>
</Field>
<Field label={t(`${base}.fields.message`)}>
<Textarea
className="rounded-none border-border min-h-16"
value={step.body ?? ""}
onChange={(e) =>
patch({ body: e.target.value } as Partial<Step>)
}
/>
</Field>
<Field label={t(`${base}.fields.channels`)}>
<div className="flex flex-wrap gap-2">
{options.channels.length === 0 && (
<span className="text-xs text-muted-foreground">
{t(`${base}.noChannelsHint`)}
</span>
)}
{options.channels.map((channel) => {
const checked = step.channelIds?.includes(channel.id);
return (
<button
key={channel.id}
type="button"
onClick={() =>
patch({
channelIds: checked
? step.channelIds.filter((id) => id !== channel.id)
: [...(step.channelIds ?? []), channel.id],
} as Partial<Step>)
}
className={`px-2 py-1 text-xs border ${
checked
? "border-primary text-foreground"
: "border-border text-muted-foreground"
}`}
>
{channel.name}
</button>
);
})}
</div>
</Field>
</>
)}
{step.type === "http" && (
<>
<div className="flex gap-2">
<Select
value={step.method}
onValueChange={(value) =>
patch({ method: value } as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="w-28 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["GET", "POST", "PUT", "PATCH", "DELETE"].map((method) => (
<SelectItem key={method} value={method}>
{method}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
className="h-8 flex-1 rounded-none border-border"
placeholder="https://"
value={step.url ?? ""}
onChange={(e) =>
patch({ url: e.target.value } as Partial<Step>)
}
/>
</div>
<Field label={t(`${base}.fields.body`)}>
<Textarea
className="rounded-none border-border min-h-16 font-mono text-xs"
value={step.body ?? ""}
onChange={(e) =>
patch({ body: e.target.value } as Partial<Step>)
}
/>
</Field>
<label className="flex items-start gap-2 text-xs text-muted-foreground">
<Switch
checked={!!step.allowPrivateNetwork}
onCheckedChange={(checked) =>
patch({ allowPrivateNetwork: checked } as Partial<Step>)
}
/>
<span>
{t(`${base}.fields.allowPrivateNetwork`)}
<span className="block">
{t(`${base}.fields.allowPrivateNetworkHint`)}
</span>
</span>
</label>
</>
)}
{step.type === "run_command" && (
<>
<HostSelectorField
value={step.hostSelector}
options={options}
onChange={(hostSelector) =>
patch({ hostSelector } as Partial<Step>)
}
/>
<Field label={t(`${base}.fields.command`)}>
<Textarea
className="rounded-none border-border min-h-16 font-mono text-xs"
value={step.command ?? ""}
onChange={(e) =>
patch({ command: e.target.value } as Partial<Step>)
}
/>
</Field>
<ElevatedToggle
checked={!!step.elevated}
onChange={(elevated) => patch({ elevated } as Partial<Step>)}
/>
</>
)}
{step.type === "run_snippet" && (
<>
<HostSelectorField
value={step.hostSelector}
options={options}
onChange={(hostSelector) =>
patch({ hostSelector } as Partial<Step>)
}
/>
<Field label={t(`${base}.fields.snippet`)}>
<Select
value={step.snippetId ? String(step.snippetId) : ""}
onValueChange={(value) =>
patch({ snippetId: Number(value) } as Partial<Step>)
}
>
<SelectTrigger size="sm" className="rounded-none border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.snippets.map((snippet) => (
<SelectItem key={snippet.id} value={String(snippet.id)}>
{snippet.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<ElevatedToggle
checked={!!step.elevated}
onChange={(elevated) => patch({ elevated } as Partial<Step>)}
/>
</>
)}
{step.type === "docker" && (
<>
<HostSelectorField
value={step.hostSelector}
options={options}
onChange={(hostSelector) =>
patch({ hostSelector } as Partial<Step>)
}
/>
<div className="flex gap-2">
<Select
value={step.action}
onValueChange={(value) =>
patch({ action: value } as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["start", "stop", "restart"].map((action) => (
<SelectItem key={action} value={action}>
{action}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
className="h-8 flex-1 rounded-none border-border"
placeholder={t(`${base}.fields.container`)}
value={step.container ?? ""}
onChange={(e) =>
patch({ container: e.target.value } as Partial<Step>)
}
/>
</div>
</>
)}
{step.type === "tunnel" && (
<div className="flex gap-2">
<Select
value={step.action}
onValueChange={(value) =>
patch({ action: value } as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="connect">connect</SelectItem>
<SelectItem value="disconnect">disconnect</SelectItem>
</SelectContent>
</Select>
<Input
className="h-8 flex-1 rounded-none border-border"
value={step.tunnelName ?? ""}
onChange={(e) =>
patch({ tunnelName: e.target.value } as Partial<Step>)
}
/>
</div>
)}
{step.type === "wol" && (
<Field label={t(`${base}.fields.host`)}>
<Select
value={step.hostId ? String(step.hostId) : ""}
onValueChange={(value) =>
patch({ hostId: Number(value) } as Partial<Step>)
}
>
<SelectTrigger size="sm" className="rounded-none border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.hosts.map((host) => (
<SelectItem key={host.id} value={String(host.id)}>
{host.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
{step.type === "wait" && (
<Field label={t(`${base}.fields.seconds`)}>
<Input
type="number"
min={1}
className="h-8 w-32 rounded-none border-border"
value={step.seconds ?? 0}
onChange={(e) =>
patch({ seconds: Number(e.target.value) } as Partial<Step>)
}
/>
</Field>
)}
{step.type === "set_var" && (
<div className="flex gap-2">
<Input
className="h-8 w-40 rounded-none border-border"
placeholder={t(`${base}.fields.variableName`)}
value={step.name ?? ""}
onChange={(e) => patch({ name: e.target.value } as Partial<Step>)}
/>
<Input
className="h-8 flex-1 rounded-none border-border"
placeholder={t(`${base}.fields.variableValue`)}
value={step.value ?? ""}
onChange={(e) =>
patch({ value: e.target.value } as Partial<Step>)
}
/>
</div>
)}
{step.type === "if" && (
<div className="space-y-3">
<div className="flex gap-2">
<Input
className="h-8 flex-1 rounded-none border-border font-mono text-xs"
placeholder={t(`${base}.fields.conditionLeftPlaceholder`)}
value={step.condition?.left ?? ""}
onChange={(e) =>
patch({
condition: { ...step.condition, left: e.target.value },
} as Partial<Step>)
}
/>
<Select
value={step.condition?.operator ?? "=="}
onValueChange={(value) =>
patch({
condition: { ...step.condition, operator: value },
} as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="w-28 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[">", "<", ">=", "<=", "==", "!=", "contains"].map((op) => (
<SelectItem key={op} value={op}>
{op}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
className="h-8 flex-1 rounded-none border-border font-mono text-xs"
placeholder={t(`${base}.fields.conditionRightPlaceholder`)}
value={step.condition?.right ?? ""}
onChange={(e) =>
patch({
condition: { ...step.condition, right: e.target.value },
} as Partial<Step>)
}
/>
</div>
{renderNested(
step.then ?? [],
(next) => patch({ then: next } as Partial<Step>),
depth + 1,
)}
</div>
)}
{step.type !== "stop" && (
<Field label={t(`${base}.fields.onError`)}>
<Select
value={step.onError ?? "stop"}
onValueChange={(value) =>
patch({ onError: value } as Partial<Step>)
}
>
<SelectTrigger
size="sm"
className="min-w-0 w-full rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stop">
{t(`${base}.onError.stop`)}
</SelectItem>
<SelectItem value="continue">
{t(`${base}.onError.continue`)}
</SelectItem>
</SelectContent>
</Select>
</Field>
)}
</div>
</div>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}
function ElevatedToggle({
checked,
onChange,
}: {
checked: boolean;
onChange: (value: boolean) => void;
}) {
const { t } = useTranslation();
return (
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<Switch checked={checked} onCheckedChange={onChange} />
{t("newUi.sidebar.automations.fields.elevated")}
</label>
);
}
/** Keeps the step id when the type changes, so run history stays linked. */
function changeStepType(step: Step, type: StepType): Step {
const next: Record<string, unknown> = { id: step.id, type };
switch (type) {
case "notify":
next.channelIds = [];
break;
case "http":
next.method = "POST";
next.url = "";
break;
case "run_command":
next.command = "";
next.hostSelector = { kind: "trigger" };
break;
case "run_snippet":
next.hostSelector = { kind: "trigger" };
break;
case "docker":
next.action = "restart";
next.container = "";
next.hostSelector = { kind: "trigger" };
break;
case "tunnel":
next.action = "connect";
next.tunnelName = "";
break;
case "wait":
next.seconds = 30;
break;
case "set_var":
next.name = "";
next.value = "";
break;
case "if":
next.condition = { left: "", operator: "==", right: "" };
next.then = [];
break;
default:
break;
}
return next as unknown as Step;
}
+425
View File
@@ -0,0 +1,425 @@
import { useTranslation } from "react-i18next";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import type { Trigger, TriggerKind } from "@/types/automations";
import { HostSelectorField } from "./HostSelectorField";
import type { AutomationEditorOptions } from "./editor-types";
const TRIGGER_KINDS: TriggerKind[] = [
"metric_threshold",
"host_status",
"health_check",
"schedule",
"docker_event",
"webhook",
];
/** Sentinel for "no zone set", which means the server's own time. */
const SERVER_TIMEZONE = "__server__";
/**
* Zones offered for a cron schedule. Uses the runtime's own list where it is
* available so the picker is complete, with a small fallback for older
* browsers that do not expose supportedValuesOf.
*/
const TIMEZONES: string[] = (() => {
try {
const supported = (
Intl as unknown as { supportedValuesOf?: (key: string) => string[] }
).supportedValuesOf?.("timeZone");
if (supported?.length) return supported;
} catch {
// Fall through to the short list.
}
return [
"UTC",
"America/New_York",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"Europe/London",
"Europe/Berlin",
"Europe/Moscow",
"Asia/Kolkata",
"Asia/Shanghai",
"Asia/Tokyo",
"Australia/Sydney",
];
})();
const METRIC_PATHS = [
"cpu.percent",
"memory.percent",
"disk.percent",
"temperature.highestCelsius",
"cpu.load1",
"processes.total",
"network.rxRateBps",
"network.txRateBps",
];
/** The single trigger at the top of an automation. */
export function TriggerCard({
trigger,
options,
onChange,
}: {
trigger: Trigger;
options: AutomationEditorOptions;
onChange: (next: Trigger) => void;
}) {
const { t } = useTranslation();
const base = "newUi.sidebar.automations";
function patch(changes: Record<string, unknown>) {
onChange({ ...trigger, ...changes } as Trigger);
}
return (
<div className="border border-border p-3 space-y-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.trigger`)}
</Label>
<Select
value={trigger.kind}
onValueChange={(value) =>
onChange(defaultTrigger(value as TriggerKind))
}
>
<SelectTrigger size="sm" className="rounded-none border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TRIGGER_KINDS.map((kind) => (
<SelectItem key={kind} value={kind}>
{t(`${base}.triggerKinds.${kind}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{trigger.kind === "metric_threshold" && (
<>
<HostSelectorField
value={trigger.hostSelector}
options={options}
allowTrigger={false}
onChange={(hostSelector) => patch({ hostSelector })}
/>
<div className="flex gap-2">
<Select
value={trigger.metric?.path ?? "cpu.percent"}
onValueChange={(value) =>
patch({ metric: { ...trigger.metric, path: value } })
}
>
<SelectTrigger
size="sm"
className="flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{METRIC_PATHS.map((path) => (
<SelectItem key={path} value={path}>
{path}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={trigger.operator}
onValueChange={(value) => patch({ operator: value })}
>
<SelectTrigger
size="sm"
className="w-20 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{[">", ">=", "<", "<=", "==", "!="].map((op) => (
<SelectItem key={op} value={op}>
{op}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
type="number"
className="h-8 w-24 rounded-none border-border"
value={trigger.value ?? 0}
onChange={(e) => patch({ value: Number(e.target.value) })}
/>
</div>
{trigger.metric?.path === "disk.percent" && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.mount`)}
</Label>
<Input
className="h-8 rounded-none border-border font-mono text-xs"
placeholder="/data"
value={trigger.metric?.mount ?? ""}
onChange={(e) =>
patch({
metric: {
...trigger.metric,
mount: e.target.value || undefined,
},
})
}
/>
</div>
)}
{trigger.metric?.path.startsWith("network.") &&
"iface" in trigger.metric && (
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.interface`)}
</Label>
<Input
className="h-8 rounded-none border-border font-mono text-xs"
placeholder="eth0"
value={trigger.metric.iface ?? ""}
onChange={(e) =>
patch({
metric: {
...trigger.metric,
iface: e.target.value || undefined,
},
})
}
/>
</div>
)}
<div className="flex gap-2">
<NumberField
label={t(`${base}.fields.forSeconds`)}
value={trigger.forSeconds ?? 0}
onChange={(value) => patch({ forSeconds: value })}
/>
<NumberField
label={t(`${base}.fields.cooldown`)}
value={trigger.cooldownMinutes ?? 15}
onChange={(value) => patch({ cooldownMinutes: value })}
/>
</div>
</>
)}
{(trigger.kind === "host_status" || trigger.kind === "health_check") && (
<>
<HostSelectorField
value={trigger.hostSelector}
options={options}
allowTrigger={false}
onChange={(hostSelector) => patch({ hostSelector })}
/>
<Select
value={trigger.to}
onValueChange={(value) => patch({ to: value })}
>
<SelectTrigger size="sm" className="rounded-none border-border">
<SelectValue />
</SelectTrigger>
<SelectContent>
{trigger.kind === "host_status" ? (
<>
<SelectItem value="offline">offline</SelectItem>
<SelectItem value="online">online</SelectItem>
</>
) : (
<>
<SelectItem value="failing">failing</SelectItem>
<SelectItem value="recovered">recovered</SelectItem>
</>
)}
</SelectContent>
</Select>
<NumberField
label={t(`${base}.fields.cooldown`)}
value={trigger.cooldownMinutes ?? 15}
onChange={(value) => patch({ cooldownMinutes: value })}
/>
</>
)}
{trigger.kind === "schedule" && (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.cron`)}
</Label>
<Input
className="h-8 rounded-none border-border font-mono text-xs"
placeholder="0 2 * * *"
value={trigger.cron ?? ""}
onChange={(e) =>
patch({ cron: e.target.value, intervalSeconds: undefined })
}
/>
</div>
<NumberField
label={t(`${base}.fields.intervalSeconds`)}
value={trigger.intervalSeconds ?? 0}
onChange={(value) =>
patch({ intervalSeconds: value || undefined, cron: undefined })
}
/>
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t(`${base}.fields.timezone`)}
</Label>
<Select
value={trigger.timezone ?? SERVER_TIMEZONE}
onValueChange={(value) =>
patch({
timezone: value === SERVER_TIMEZONE ? undefined : value,
})
}
>
<SelectTrigger
size="sm"
className="w-full rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={SERVER_TIMEZONE}>
{t(`${base}.fields.timezoneServer`)}
</SelectItem>
{TIMEZONES.map((zone) => (
<SelectItem key={zone} value={zone}>
{zone}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t(`${base}.fields.timezoneHint`)}
</p>
</div>
</div>
)}
{trigger.kind === "docker_event" && (
<>
<HostSelectorField
value={trigger.hostSelector}
options={options}
allowTrigger={false}
onChange={(hostSelector) => patch({ hostSelector })}
/>
<div className="flex gap-2">
<Input
className="h-8 flex-1 rounded-none border-border"
placeholder={t(`${base}.fields.container`)}
value={trigger.container ?? ""}
onChange={(e) => patch({ container: e.target.value })}
/>
<Select
value={trigger.event}
onValueChange={(value) => patch({ event: value })}
>
<SelectTrigger
size="sm"
className="min-w-0 flex-1 rounded-none border-border"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["exited", "started", "unhealthy", "restarting"].map((e) => (
<SelectItem key={e} value={e}>
{e}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{trigger.kind === "webhook" && (
<p className="text-xs text-muted-foreground">
{t(`${base}.webhookTokenDescription`)}
</p>
)}
</div>
);
}
function NumberField({
label,
value,
onChange,
}: {
label: string;
value: number;
onChange: (value: number) => void;
}) {
return (
<div className="space-y-1 flex-1">
<Label className="text-xs text-muted-foreground">{label}</Label>
<Input
type="number"
min={0}
className="h-8 rounded-none border-border"
value={value}
onChange={(e) => onChange(Number(e.target.value))}
/>
</div>
);
}
export function defaultTrigger(kind: TriggerKind): Trigger {
switch (kind) {
case "host_status":
return {
kind: "host_status",
hostSelector: { kind: "all" },
to: "offline",
cooldownMinutes: 15,
};
case "health_check":
return {
kind: "health_check",
hostSelector: { kind: "all" },
to: "failing",
cooldownMinutes: 15,
};
case "schedule":
return { kind: "schedule", cron: "0 2 * * *" };
case "docker_event":
return {
kind: "docker_event",
hostSelector: { kind: "all" },
event: "exited",
cooldownMinutes: 15,
};
case "webhook":
return { kind: "webhook", tokenHash: "" };
default:
return {
kind: "metric_threshold",
hostSelector: { kind: "all" },
metric: { path: "cpu.percent" },
operator: ">",
value: 90,
forSeconds: 300,
cooldownMinutes: 15,
};
}
}
@@ -0,0 +1,19 @@
/** Lookup data the editor needs to turn ids into names. */
export interface AutomationEditorOptions {
hosts: Array<{ id: number; name: string }>;
snippets: Array<{ id: number; name: string }>;
channels: Array<{ id: number; name: string }>;
fleets: Array<{ id: number; name: string }>;
}
export const EMPTY_EDITOR_OPTIONS: AutomationEditorOptions = {
hosts: [],
snippets: [],
channels: [],
fleets: [],
};
/** Short unique id for a new step, stable for the life of the automation. */
export function newStepId(): string {
return `s${Math.random().toString(36).slice(2, 8)}`;
}
+110
View File
@@ -0,0 +1,110 @@
import type { SSHHostWithStatus } from "@/main-axios";
import type { HostFolder } from "@/types/ui-types";
import { sshHostToHost } from "./HostManagerData";
/**
* Builds the sidebar's host tree from the flat host list returned by the
* API, nesting via two independent mechanisms:
*
* - folder: a " / "-separated string path, split into synthetic HostFolder
* nodes (folders are virtual -- they only exist as this derived tree).
* - parentHostId: sub-host nesting, where a real host acts as an
* organizational parent for other real hosts. A host with children stays
* a normal, connectable Host -- it is never wrapped in a synthetic node,
* just given a childHosts array the tree/virtualizer walk into.
*
* The backend already enforces that folder and parentHostId are mutually
* exclusive on write and rejects cycles, but this still defends against
* stale/imported/synced data: a missing, inaccessible, or cyclic parent
* falls back to folder/root placement rather than being dropped or looping.
*/
export function buildHostTree(
hosts: SSHHostWithStatus[],
folderMeta?: Map<
string,
{
color?: string;
icon?: string;
credentialId?: number | null;
sortOrder?: number | null;
}
>,
): HostFolder {
const root: HostFolder = { name: "root", children: [] };
const folderMap = new Map<string, HostFolder>();
const getOrCreateFolder = (path: string): HostFolder => {
if (folderMap.has(path)) return folderMap.get(path)!;
const parts = path.split(" / ");
let current = root;
let accumulated = "";
for (const part of parts) {
accumulated = accumulated ? `${accumulated} / ${part}` : part;
if (!folderMap.has(accumulated)) {
const meta = folderMeta?.get(accumulated);
const folder: HostFolder = {
name: part,
path: accumulated,
color: meta?.color,
icon: meta?.icon,
credentialId: meta?.credentialId ?? null,
sortOrder: meta?.sortOrder ?? null,
children: [],
};
folderMap.set(accumulated, folder);
current.children.push(folder);
}
current = folderMap.get(accumulated)!;
}
return current;
};
// Surface empty folders (created but with no hosts yet) so they stay visible.
if (folderMeta) {
for (const path of folderMeta.keys()) getOrCreateFolder(path);
}
const mappedHosts = hosts.map((h) => sshHostToHost(h));
const hostsById = new Map(mappedHosts.map((h) => [h.id, h]));
// The backend rejects cycles on write, but stale/imported/synced data could
// still contain one -- walking a candidate parent's own ancestor chain
// before attaching guards the tree build against an infinite loop.
function isDescendantOf(candidateId: string, ancestorId: string): boolean {
let current: string | null | undefined = candidateId;
const visited = new Set<string>();
while (current) {
if (current === ancestorId) return true;
if (visited.has(current)) return false;
visited.add(current);
current = hostsById.get(current)?.parentHostId;
}
return false;
}
// Sub-host nesting (parentHostId) takes priority over folder placement --
// the backend already clears folder when parentHostId is set and vice
// versa, but a host is placed by whichever it currently has.
const nested = new Set<string>();
for (const host of mappedHosts) {
if (!host.parentHostId) continue;
const parent = hostsById.get(host.parentHostId);
// A parent that's missing (deleted), not visible to this user (e.g. a
// shared host whose parent link was stripped server-side), or would form
// a cycle falls back to folder/root placement below, same as any other
// orphaned host.
if (!parent) continue;
if (isDescendantOf(host.parentHostId, host.id)) continue;
parent.childHosts = parent.childHosts ?? [];
parent.childHosts.push(host);
nested.add(host.id);
}
for (const host of mappedHosts) {
if (nested.has(host.id)) continue;
if (host.folder) {
getOrCreateFolder(host.folder).children.push(host);
} else {
root.children.push(host);
}
}
return root;
}
+68
View File
@@ -0,0 +1,68 @@
import type { Credential } from "@/types/ui-types";
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
/**
* Sorts a flat credential list. No tree recursion needed -- unlike hosts,
* credential folders are explicitly flat, so this is a plain array sort.
*/
export function sortCredentials(
creds: Credential[],
key: CredentialSortKey,
pinnedFirst = false,
): Credential[] {
const sorted = [...creds];
switch (key) {
case "name-asc":
sorted.sort((a, b) => a.name.localeCompare(b.name));
break;
case "name-desc":
sorted.sort((a, b) => b.name.localeCompare(a.name));
break;
case "username-asc":
sorted.sort((a, b) => a.username.localeCompare(b.username));
break;
case "username-desc":
sorted.sort((a, b) => b.username.localeCompare(a.username));
break;
case "manual":
sorted.sort((a, b) => {
const aOrder = a.sortOrder ?? null;
const bOrder = b.sortOrder ?? null;
if (aOrder == null && bOrder == null)
return a.name.localeCompare(b.name);
if (aOrder == null) return 1;
if (bOrder == null) return -1;
if (aOrder !== bOrder) return aOrder - bOrder;
return a.name.localeCompare(b.name);
});
break;
case "default":
default:
break;
}
if (pinnedFirst) {
sorted.sort((a, b) => {
const aPinned = a.pin ? 1 : 0;
const bPinned = b.pin ? 1 : 0;
return bPinned - aPinned;
});
}
return sorted;
}
export function credentialPassesFilters(
cred: Credential,
filters: { type: ("password" | "key")[]; tags: string[] },
): boolean {
if (filters.type.length > 0 && !filters.type.includes(cred.type))
return false;
if (
filters.tags.length > 0 &&
!filters.tags.some((tag) => cred.tags?.includes(tag))
)
return false;
return true;
}
@@ -0,0 +1,357 @@
import { useTranslation } from "react-i18next";
import {
ChevronRight,
Copy,
GripVertical,
Pencil,
Pin,
Trash2,
Upload,
} from "lucide-react";
import { toast } from "sonner";
import { copyToClipboard } from "@/lib/clipboard";
import type { Credential } from "@/types/ui-types";
import type {
CredentialDensity,
CredentialTrayTrigger,
} from "@/types/credential-sidebar-preferences";
export function credentialMatchesQuery(cred: Credential, query: string) {
return (
cred.name.toLowerCase().includes(query) ||
cred.username.toLowerCase().includes(query) ||
cred.tags?.some((t) => t.toLowerCase().includes(query))
);
}
/**
* Per-density layout knobs, mirroring HOST_ITEM_DENSITY_TOKENS in
* HostItem.tsx -- one implementation with a token lookup instead of two
* parallel JSX trees, so a future tweak only has to be made once.
*/
const CREDENTIAL_ITEM_DENSITY_TOKENS = {
comfortable: {
rowPadding: "pl-2.5 pr-2 py-2",
nameTextSize: "text-[13px]",
showUsernameRow: true,
showTagsRow: true,
},
compact: {
rowPadding: "pl-2 pr-1.5 py-[5px]",
nameTextSize: "text-[12px]",
showUsernameRow: false,
showTagsRow: false,
},
} as const;
export function CredentialItem({
cred,
usedByCount = 0,
termixIdLinked = false,
query = "",
stripeIndex = 0,
isMenuOpen = false,
onMenuOpenChange,
isTrayOpen = false,
onTrayOpenChange,
onDragStart,
onDragEnd,
depth = 0,
density = "comfortable",
trayTrigger = "hover",
showTags = true,
arrangeMode = false,
isDragging = false,
onReorderDrop,
isReorderHovered = false,
reorderHoverEdge = null,
onReorderHoverChange,
onDeploy,
onEdit,
onDelete,
}: {
cred: Credential;
usedByCount?: number;
termixIdLinked?: boolean;
query?: string;
stripeIndex?: number;
isMenuOpen?: boolean;
onMenuOpenChange?: (open: boolean) => void;
isTrayOpen?: boolean;
onTrayOpenChange?: (open: boolean) => void;
onDragStart?: () => void;
onDragEnd?: () => void;
/** Nesting level when rendered in a flattened virtual list. */
depth?: number;
density?: CredentialDensity;
trayTrigger?: CredentialTrayTrigger;
showTags?: boolean;
/** When true (rearranging unlocked), the row can be dragged to reorder or
* onto a folder header to move. */
arrangeMode?: boolean;
/** True while this row is the one being dragged. */
isDragging?: boolean;
onReorderDrop?: (position: "before" | "after") => void;
/** Whether THIS row is the current reorder drop target -- lifted to the
* parent tree so only one row can ever show the drop-indicator bar at a
* time. See HostItem's identical prop for the full rationale. */
isReorderHovered?: boolean;
reorderHoverEdge?: "before" | "after" | null;
onReorderHoverChange?: (edge: "before" | "after" | null) => void;
onDeploy: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const reorderEdge = isReorderHovered ? reorderHoverEdge : null;
const isKey = cred.type === "key";
const tokens = CREDENTIAL_ITEM_DENSITY_TOKENS[density];
const isCompact = density === "compact";
const isTouchOnly =
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
const alwaysShowTray = trayTrigger === "always";
const actionsOnly = trayTrigger === "actionsOnly";
const shouldUseClickTray =
!alwaysShowTray && !actionsOnly && (trayTrigger === "click" || isTouchOnly);
const canDrag = arrangeMode && !isTouchOnly;
const depthStyle =
depth > 0 ? ({ paddingLeft: depth * 12 } as const) : undefined;
const trayButtonClass =
"flex items-center justify-center size-6.5 text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors";
const connectionButtons = isKey ? (
<>
<button
title={t("credentials.deploySSHKeyTitle")}
onClick={(e) => {
e.stopPropagation();
onDeploy();
}}
className={trayButtonClass}
>
<Upload className="size-3.5" />
</button>
<button
title={t("credentials.copyDeployCommand")}
onClick={async (e) => {
e.stopPropagation();
const pubKey = cred.publicKey;
if (!pubKey) {
toast.error(t("credentials.noPublicKeyAvailable"));
return;
}
const cmd = `mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo "${pubKey}" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`;
const ok = await copyToClipboard(cmd);
if (ok) toast.success(t("credentials.deployCommandCopied"));
else toast.error(t("common.copyFailed"));
}}
className={trayButtonClass}
>
<Copy className="size-3.5" />
</button>
</>
) : null;
const managementButtons = (
<>
<button
title={t("credentials.editCredentialAction")}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
className={trayButtonClass}
>
<Pencil className="size-3.5" />
</button>
<button
title={t("credentials.deleteCredentialAction")}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className={`${trayButtonClass} hover:text-destructive`}
>
<Trash2 className="size-3.5" />
</button>
</>
);
const trayOpenState = isTrayOpen || isMenuOpen;
const trayVisibilityClass =
alwaysShowTray || actionsOnly
? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState || alwaysShowTray ? "max-h-[60px] opacity-100" : "max-h-0 opacity-0"}`
: shouldUseClickTray
? `overflow-hidden transition-all duration-150 ease-out ${trayOpenState ? "max-h-[60px] opacity-100" : "max-h-0 opacity-0"}`
: `overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 group-hover:max-h-[60px] group-hover:opacity-100 ${isMenuOpen ? "!max-h-[60px] !opacity-100" : ""}`;
return (
<div
draggable={canDrag}
onDragStart={(e) => {
if (!canDrag) return;
e.dataTransfer.effectAllowed = "move";
// Drag the name row, not the whole row with its open tray, so the
// ghost stays a compact label instead of a tall overlapping block.
const label =
e.currentTarget.querySelector<HTMLElement>("[data-drag-label]");
if (label) {
const rect = label.getBoundingClientRect();
e.dataTransfer.setDragImage(
label,
Math.min(e.clientX - rect.left, rect.width),
rect.height / 2,
);
}
onDragStart?.();
}}
onDragEnd={() => {
onReorderHoverChange?.(null);
onDragEnd?.();
}}
onDragOver={(e) => {
if (!arrangeMode || !onReorderDrop) return;
e.preventDefault();
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
onReorderHoverChange?.(
e.clientY - rect.top < rect.height / 2 ? "before" : "after",
);
}}
onDrop={(e) => {
if (!arrangeMode || !onReorderDrop || !reorderEdge) return;
e.preventDefault();
e.stopPropagation();
onReorderDrop(reorderEdge);
onReorderHoverChange?.(null);
}}
style={depthStyle}
className={`group relative flex items-stretch select-none transition-colors hover:bg-muted/50 ${
canDrag ? "cursor-grab active:cursor-grabbing" : "cursor-default"
} ${stripeIndex % 2 === 1 ? "bg-muted/15" : ""} ${isMenuOpen ? "bg-muted/50" : ""} ${isDragging ? "opacity-40" : ""}`}
>
{canDrag && (
<div className="flex items-center justify-center w-4 shrink-0 text-muted-foreground/35 group-hover:text-muted-foreground/70 transition-colors">
<GripVertical className="size-3" />
</div>
)}
<div
className={`flex flex-col flex-1 min-w-0 ${tokens.rowPadding} ${isCompact ? "" : "gap-1"}`}
>
{/* Name row */}
<div data-drag-label className="flex items-center gap-1.5 min-w-0">
<span
className={`${tokens.nameTextSize} font-semibold truncate text-foreground leading-none tracking-tight`}
>
{cred.name}
</span>
<span
className={`text-[9px] px-1 py-px font-bold border leading-none shrink-0 ${isKey ? "border-accent-brand/30 text-accent-brand" : "border-border/60 text-muted-foreground/60"}`}
>
{isKey ? t("credentials.keyBadge") : t("credentials.passwordBadge")}
</span>
{termixIdLinked && (
<span className="text-[9px] px-1 py-px font-bold border leading-none shrink-0 border-accent-brand/30 text-accent-brand/70">
{t("credentials.idBadge")}
</span>
)}
{cred.pin && (
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
)}
{!shouldUseClickTray && !actionsOnly && (
<span className="text-[11px] text-muted-foreground/45 truncate leading-none ml-auto shrink-0 group-hover:hidden">
{usedByCount > 0 ? `${usedByCount}h` : ""}
</span>
)}
{(shouldUseClickTray || actionsOnly) && (
<button
title={
isTrayOpen
? t("credentials.collapseActions")
: t("credentials.expandActions")
}
onClick={(e) => {
e.stopPropagation();
onTrayOpenChange?.(!isTrayOpen);
}}
className="ml-auto flex items-center justify-center size-5 rounded text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted-foreground/10 transition-colors shrink-0"
>
<ChevronRight
className={`size-3 transition-transform duration-150 ${isTrayOpen ? "rotate-90" : ""}`}
/>
</button>
)}
</div>
{/* Username row */}
{tokens.showUsernameRow && (cred.username || usedByCount > 0) && (
<span className="text-[11px] text-muted-foreground/60 truncate leading-none font-mono">
{cred.username}
{usedByCount > 0 && (
<span className="text-muted-foreground/40">
{cred.username ? " · " : ""}
{usedByCount}h
</span>
)}
</span>
)}
{/* Tag pills */}
{showTags && cred.tags && cred.tags.length > 0 && (
<div
className={`flex items-center gap-1 min-w-0 overflow-hidden ${tokens.showTagsRow ? "" : "-mt-0.5"}`}
>
{cred.tags.slice(0, isCompact ? 2 : 4).map((tag) => (
<span
key={tag}
className="text-[9px] px-1.5 py-[1px] bg-muted/60 text-muted-foreground/70 lowercase shrink-0 leading-[1.4]"
>
{tag}
</span>
))}
{cred.tags.length > (isCompact ? 2 : 4) && (
<span className="text-[9px] text-muted-foreground/40 shrink-0">
+{cred.tags.length - (isCompact ? 2 : 4)}
</span>
)}
</div>
)}
{/* Connection buttons: permanent in "always"/"actionsOnly" modes, or shown once the chevron opens the tray in click mode */}
{connectionButtons &&
(alwaysShowTray ||
actionsOnly ||
(shouldUseClickTray && isTrayOpen)) && (
<div className="flex items-center flex-wrap gap-1">
{connectionButtons}
</div>
)}
{/* Action tray */}
<div className={trayVisibilityClass}>
<div
className={`flex flex-col gap-0.5 ${alwaysShowTray || actionsOnly || shouldUseClickTray ? "" : "pt-1.5"}`}
>
{connectionButtons &&
!alwaysShowTray &&
!actionsOnly &&
!shouldUseClickTray && (
<div className="flex items-center flex-wrap gap-0.5">
{connectionButtons}
</div>
)}
<div
className={`flex items-center gap-0.5 border-t border-border/30 ${alwaysShowTray || actionsOnly || shouldUseClickTray ? "pt-1.5" : "pt-1 mt-0.5"}`}
>
{managementButtons}
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,481 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { useVirtualizer } from "@tanstack/react-virtual";
import { KeyRound, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { reorderCredentials } from "@/main-axios";
import { planReorder, renumberSiblings } from "@/sidebar/reorder-utils";
import { ReorderIndicator } from "@/sidebar/ReorderIndicator";
import type { Credential } from "@/types/ui-types";
import type {
CredentialDensity,
CredentialTrayTrigger,
} from "@/types/credential-sidebar-preferences";
import { CredentialItem } from "./CredentialItem/CredentialItem";
import { CredentialFolderItem } from "./FolderItem/CredentialFolderItem";
import {
isFolder,
collectVisibleRows,
collectAllFolderNames,
type CredentialFolder,
} from "./visible-rows";
export function CredentialSidebarTree({
folders,
onDeployCredential,
onEditCredential,
onDeleteCredential,
usedByCounts,
termixIdLinkedIds,
query = "",
loading = false,
arrangeLocked = true,
density = "comfortable",
trayTrigger = "hover",
showTags = true,
editingFolderName,
editingFolderValue,
onEditingFolderNameChange,
onEditingFolderValueChange,
onRenameFolder,
onDeleteFolder,
onMoveCredentialToFolder,
}: {
folders: CredentialFolder[];
onDeployCredential: (cred: Credential) => void;
onEditCredential: (cred: Credential) => void;
onDeleteCredential: (cred: Credential) => void;
usedByCounts?: Map<string, number>;
termixIdLinkedIds?: Set<number>;
query?: string;
loading?: boolean;
/** When true, drag-to-rearrange is off entirely. Toggled from the panel header. */
arrangeLocked?: boolean;
density?: CredentialDensity;
trayTrigger?: CredentialTrayTrigger;
showTags?: boolean;
editingFolderName: string | null;
editingFolderValue: string;
onEditingFolderNameChange: (name: string | null) => void;
onEditingFolderValueChange: (value: string) => void;
onRenameFolder: (folder: string, newName: string) => Promise<void>;
onDeleteFolder?: (folder: string) => void;
/** Drop a credential on a folder header to reassign it. */
onMoveCredentialToFolder?: (
credentialId: string,
targetFolder: string,
) => void | Promise<void>;
}) {
const { t } = useTranslation();
const parentRef = useRef<HTMLDivElement>(null);
const [openFolders, setOpenFolders] = useState<Set<string>>(
() => new Set(collectAllFolderNames(folders)),
);
const seenFolderNames = useRef<Set<string>>(
new Set(collectAllFolderNames(folders)),
);
// Folders default to open, but the initial render often happens before
// credentials finish loading (folders === []), so the useState initializer
// above freezes on an empty set. Newly-appearing folder names (including
// the very first load) are added to the open set as they show up; a
// folder the user has explicitly closed stays closed on future renders
// since it's already tracked in seenFolderNames.
useEffect(() => {
const currentNames = collectAllFolderNames(folders);
const unseen = currentNames.filter((n) => !seenFolderNames.current.has(n));
if (unseen.length === 0) return;
for (const name of unseen) seenFolderNames.current.add(name);
setOpenFolders((prev) => {
const next = new Set(prev);
for (const name of unseen) next.add(name);
return next;
});
}, [folders]);
const [openTrayCredentialId, setOpenTrayCredentialId] = useState<
string | null
>(null);
const [openMenuCredentialId, setOpenMenuCredentialId] = useState<
string | null
>(null);
const [draggedReorderKey, setDraggedReorderKey] = useState<string | null>(
null,
);
// Tracks the single credential being dragged for folder reassignment,
// separate from draggedReorderKey (which drives manual-sort-mode
// position reordering). Only active outside manual sort mode.
const [draggedCredentialId, setDraggedCredentialId] = useState<string | null>(
null,
);
// Tracks which single row is currently the manual-reorder drop target,
// lifted here rather than local per-row state so only one row can ever
// show the drop-indicator bar at a time. See HostItem/SidebarTree's
// identical fix for the full rationale.
const [reorderHoverKey, setReorderHoverKey] = useState<string | null>(null);
const [reorderHoverEdge, setReorderHoverEdge] = useState<
"before" | "after" | null
>(null);
const arrangeMode = !arrangeLocked;
const isTouchOnly =
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
const alwaysShowActions = trayTrigger === "always";
const actionsOnly = trayTrigger === "actionsOnly";
const clickTrayActive =
!alwaysShowActions &&
!actionsOnly &&
(trayTrigger === "click" || isTouchOnly);
const isCompactDensity = density === "compact";
const visibleRows = collectVisibleRows(folders, query, openFolders);
// Fixed, exactly-computed row heights rather than estimate-then-measure,
// matching SidebarTree.tsx's approach -- estimate-then-measure caused
// visible gaps between rows there. All constants below were measured live
// against the running app (Playwright getBoundingClientRect) for every
// density x trayTrigger x open/closed-tray x credential-type combination.
//
// Unlike hosts, credential row height genuinely depends on TYPE, not just
// density/trayTrigger: only "key" credentials render a connection-buttons
// row (deploy/copy-command) -- "password" credentials have no connection
// buttons at all, so a password row is shorter than a key row whenever
// that row is showing (actionsOnly closed, or always mode).
const FOLDER_ROW_HEIGHT = 31.5;
// Base closed height with no persistent extras (hover/click modes,
// collapsed) -- identical for both credential types since neither the
// connection row nor the management row is rendered. Re-measured live
// after fixing the comfortable-density row's top/bottom padding asymmetry
// (was pt-2.5 pb-2, now the symmetric py-2, 2px shorter).
const BASE_ROW_HEIGHT = isCompactDensity ? 23 : 45;
// Fully opened (management row revealed, plus connection row for key
// creds) -- differs by type since only key creds have a connection row;
// measured identical for click-mode-open and actionsOnly-mode-open.
const OPEN_KEY_ROW_HEIGHT = isCompactDensity ? 79.25 : 104.75;
const OPEN_PASSWORD_ROW_HEIGHT = isCompactDensity ? 56.5 : 78.5;
// actionsOnly closed (connection row shown permanently, management row
// still collapsed) -- differs by type since only key creds have a
// connection row to show.
const ACTIONS_ONLY_KEY_ROW_HEIGHT = isCompactDensity ? 50.25 : 75.75;
const ACTIONS_ONLY_PASSWORD_ROW_HEIGHT = isCompactDensity ? 27.5 : 49.5;
// always mode (both rows permanently shown) -- measured slightly shorter
// than a click-opened row since the chevron toggle button isn't rendered.
const ALWAYS_KEY_ROW_HEIGHT = isCompactDensity ? 74.75 : 100.25;
const ALWAYS_PASSWORD_ROW_HEIGHT = isCompactDensity ? 52 : 74;
const rowHeight = useCallback(
(index: number) => {
const row = visibleRows[index];
if (!row) return FOLDER_ROW_HEIGHT;
if (isFolder(row.item)) return FOLDER_ROW_HEIGHT;
const isKey = row.item.type === "key";
if (alwaysShowActions) {
return isKey ? ALWAYS_KEY_ROW_HEIGHT : ALWAYS_PASSWORD_ROW_HEIGHT;
}
const isOpen =
(openTrayCredentialId === row.item.id ||
openMenuCredentialId === row.item.id) &&
(clickTrayActive || actionsOnly);
if (actionsOnly) {
if (isOpen)
return isKey ? OPEN_KEY_ROW_HEIGHT : OPEN_PASSWORD_ROW_HEIGHT;
return isKey
? ACTIONS_ONLY_KEY_ROW_HEIGHT
: ACTIONS_ONLY_PASSWORD_ROW_HEIGHT;
}
if (isOpen) return isKey ? OPEN_KEY_ROW_HEIGHT : OPEN_PASSWORD_ROW_HEIGHT;
return BASE_ROW_HEIGHT;
},
[
visibleRows,
openTrayCredentialId,
openMenuCredentialId,
clickTrayActive,
alwaysShowActions,
actionsOnly,
BASE_ROW_HEIGHT,
OPEN_KEY_ROW_HEIGHT,
OPEN_PASSWORD_ROW_HEIGHT,
ACTIONS_ONLY_KEY_ROW_HEIGHT,
ACTIONS_ONLY_PASSWORD_ROW_HEIGHT,
ALWAYS_KEY_ROW_HEIGHT,
ALWAYS_PASSWORD_ROW_HEIGHT,
],
);
const virtualizer = useVirtualizer({
count: visibleRows.length,
getScrollElement: () => parentRef.current,
estimateSize: rowHeight,
overscan: 12,
getItemKey: (index) => {
const row = visibleRows[index];
if (!row) return index;
return isFolder(row.item)
? `folder:${row.item.name}`
: `cred:${row.item.id}`;
},
});
// Single tree-level indicator off the virtualizer's slot geometry -- see
// SidebarTree for why rows can't place this themselves.
const reorderIndicatorTop = (() => {
if (!arrangeMode || !reorderHoverKey || !reorderHoverEdge) return null;
const index = visibleRows.findIndex((row) => {
const key = isFolder(row.item)
? `folder:${row.item.name}`
: `cred:${row.item.id}`;
return key === reorderHoverKey;
});
if (index === -1) return null;
const slot = virtualizer
.getVirtualItems()
.find((vItem) => vItem.index === index);
if (!slot) return null;
return reorderHoverEdge === "before" ? slot.start : slot.start + slot.size;
})();
useLayoutEffect(() => {
virtualizer.measure();
}, [
virtualizer,
openFolders,
openTrayCredentialId,
openMenuCredentialId,
query,
visibleRows.length,
density,
trayTrigger,
]);
function toggleFolder(name: string) {
setOpenFolders((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}
/**
* Resolves a credential drop into its new position, scoped to the target
* credential's own folder, and moves it there when the drop crossed
* folders. Credential folders themselves are always alphabetical -- they
* have no stored order, so only credentials are draggable.
*/
/**
* Credential dropped on a folder header: lands at the end of that folder.
* It needs an explicit sortOrder for the same reason hosts do -- a null
* one sorts last under manual sort and then falls back to alphabetical,
* throwing the placement away.
*/
async function handleDropIntoFolder(credentialId: string, folder: string) {
const destination = folders
.find((f) => f.name === folder)
?.children.filter((c) => c.id !== credentialId);
try {
await onMoveCredentialToFolder?.(credentialId, folder);
if (destination) {
const ordered = [
...destination.filter((c) => c.sortOrder != null),
...destination.filter((c) => c.sortOrder == null),
];
await reorderCredentials(
renumberSiblings([...ordered, { id: credentialId }]).map((r) => ({
id: Number(r.id),
sortOrder: r.sortOrder,
})),
);
}
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
} catch {
toast.error(t("credentials.failedToReorder"));
}
}
async function handleReorderDrop(
targetKey: string,
position: "before" | "after",
) {
const draggedKey = draggedReorderKey;
setDraggedReorderKey(null);
setReorderHoverKey(null);
setReorderHoverEdge(null);
if (!draggedKey) return;
if (!draggedKey.startsWith("cred:") || !targetKey.startsWith("cred:"))
return;
// From every folder's children, not visibleRows: a collapsed folder
// contributes no visible rows, so planning off the rendered list
// compared against a partial sibling group.
const rows = folders.flatMap((folder) =>
folder.children.map((cred) => ({
key: `cred:${cred.id}`,
parentKey: `folder:${cred.folder || "Uncategorized"}`,
sortOrder: cred.sortOrder,
})),
);
const plan = planReorder(rows, draggedKey, targetKey, position);
if (!plan) return;
const draggedId = draggedKey.slice("cred:".length);
try {
if (plan.movedTo !== null) {
const folder = plan.movedTo.slice("folder:".length);
await onMoveCredentialToFolder?.(draggedId, folder);
}
await reorderCredentials(
plan.positions.map((pos) => ({
id: Number(pos.key.slice("cred:".length)),
sortOrder: pos.sortOrder,
})),
);
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
} catch {
toast.error(t("credentials.failedToReorder"));
}
}
if (loading) {
return (
<div className="relative flex flex-col flex-1 min-h-0">
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2 space-y-1.5">
{[60, 45, 55, 40].map((w, i) => (
<div key={i} className="flex items-center gap-2 px-3 py-2">
<div className="size-3 bg-muted/50 animate-pulse shrink-0" />
<div
className="h-3 bg-muted/50 animate-pulse"
style={{ width: `${w * 2}px` }}
/>
</div>
))}
<div className="flex items-center justify-center gap-2 pt-2 text-muted-foreground/40">
<Loader2 className="size-3.5 animate-spin" />
<span className="text-xs">
{t("credentials.loadingCredentials")}
</span>
</div>
</div>
</div>
);
}
return (
<div className="relative flex flex-col flex-1 min-h-0">
<div ref={parentRef} className="flex-1 min-h-0 overflow-y-auto">
{visibleRows.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
<KeyRound className="size-8 text-muted-foreground/20 mb-2" />
<span className="text-sm font-semibold text-muted-foreground/60">
{query
? t("credentials.noCredentialsMatchSearch")
: t("credentials.noCredentialsFound")}
</span>
</div>
) : (
<div
className="relative w-full"
style={{ height: virtualizer.getTotalSize() }}
>
{reorderIndicatorTop !== null && (
<ReorderIndicator top={reorderIndicatorTop} />
)}
{virtualizer.getVirtualItems().map((vItem) => {
const row = visibleRows[vItem.index];
if (!row) return null;
const { item, depth } = row;
return (
<div
key={vItem.key}
data-index={vItem.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full"
style={{ transform: `translateY(${vItem.start}px)` }}
>
{isFolder(item) ? (
<CredentialFolderItem
folder={item}
isOpen={openFolders.has(item.name)}
stripeIndex={vItem.index}
query={query}
editingFolderName={editingFolderName}
editingFolderValue={editingFolderValue}
onToggleFolder={toggleFolder}
onEditingFolderNameChange={onEditingFolderNameChange}
onEditingFolderValueChange={onEditingFolderValueChange}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
depth={depth}
arrangeMode={arrangeMode}
draggedCredentialId={draggedCredentialId}
onDropCredential={(credentialId) => {
setDraggedCredentialId(null);
void handleDropIntoFolder(credentialId, item.name);
}}
/>
) : (
<CredentialItem
cred={item}
usedByCount={usedByCounts?.get(item.id) ?? 0}
termixIdLinked={termixIdLinkedIds?.has(Number(item.id))}
query={query}
stripeIndex={vItem.index}
isMenuOpen={openMenuCredentialId === item.id}
onMenuOpenChange={(open) =>
setOpenMenuCredentialId(open ? item.id : null)
}
isTrayOpen={openTrayCredentialId === item.id}
onTrayOpenChange={(open) =>
setOpenTrayCredentialId(open ? item.id : null)
}
onDragStart={() => {
// Both arm together: the drop target picks whether
// this ends up a reorder or a folder move.
setDraggedReorderKey(`cred:${item.id}`);
setDraggedCredentialId(item.id);
}}
onDragEnd={() => {
setDraggedReorderKey(null);
setDraggedCredentialId(null);
setReorderHoverKey(null);
setReorderHoverEdge(null);
}}
depth={depth}
density={density}
trayTrigger={trayTrigger}
showTags={showTags}
arrangeMode={arrangeMode}
isDragging={draggedReorderKey === `cred:${item.id}`}
onReorderDrop={(position) =>
handleReorderDrop(`cred:${item.id}`, position)
}
isReorderHovered={reorderHoverKey === `cred:${item.id}`}
reorderHoverEdge={reorderHoverEdge}
onReorderHoverChange={(edge) => {
setReorderHoverKey(edge ? `cred:${item.id}` : null);
setReorderHoverEdge(edge);
}}
onDeploy={() => onDeployCredential(item)}
onEdit={() => onEditCredential(item)}
onDelete={() => onDeleteCredential(item)}
/>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,173 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronRight,
Folder,
FolderOpen,
Pencil,
Trash2,
X,
} from "lucide-react";
import type { CredentialFolder } from "../visible-rows";
const UNCATEGORIZED = "Uncategorized";
export function CredentialFolderItem({
folder,
isOpen,
stripeIndex,
query = "",
editingFolderName,
editingFolderValue,
onToggleFolder,
onEditingFolderNameChange,
onEditingFolderValueChange,
onRenameFolder,
onDeleteFolder,
depth = 0,
arrangeMode = false,
draggedCredentialId,
onDropCredential,
}: {
folder: CredentialFolder;
isOpen: boolean;
stripeIndex?: number;
query?: string;
editingFolderName: string | null;
editingFolderValue: string;
onToggleFolder: (name: string) => void;
onEditingFolderNameChange: (name: string | null) => void;
onEditingFolderValueChange: (value: string) => void;
onRenameFolder: (folder: string, newName: string) => Promise<void>;
onDeleteFolder?: (folder: string) => void;
depth?: number;
/** When true (rearranging unlocked), the header accepts dropped
* credentials. Credential folders themselves have no stored order, so
* they are never draggable. */
arrangeMode?: boolean;
/** Id of the credential currently being dragged (outside manual sort
* mode), if any -- drives the drop-target highlight below. */
draggedCredentialId?: string | null;
/** Fires when a dragged credential is dropped on this folder header to
* reassign it, outside manual sort mode. */
onDropCredential?: (credentialId: string) => void;
}) {
const { t } = useTranslation();
const [dragOver, setDragOver] = useState(false);
const isRenaming = editingFolderName === folder.name;
const canManage = folder.name !== UNCATEGORIZED;
const actionButtonClass =
"flex items-center justify-center size-6 text-muted-foreground/60 hover:text-foreground hover:bg-background/80 transition-colors";
const acceptsCredentialDrop = arrangeMode && !!draggedCredentialId;
return (
<div
className="relative"
style={depth > 0 ? { paddingLeft: depth * 12 } : undefined}
>
<div
onDragOver={(e) => {
if (!acceptsCredentialDrop) return;
e.preventDefault();
e.stopPropagation();
setDragOver(true);
}}
onDragLeave={(e) => {
if (e.currentTarget === e.target) setDragOver(false);
}}
onDrop={(e) => {
if (!acceptsCredentialDrop || !draggedCredentialId) return;
e.preventDefault();
e.stopPropagation();
setDragOver(false);
onDropCredential?.(draggedCredentialId);
}}
onClick={() => !query && !isRenaming && onToggleFolder(folder.name)}
className={`group/folder flex items-center gap-2 w-full pl-2.5 pr-2 py-1.5 transition-colors text-left cursor-pointer ${
isOpen ? "bg-muted/40" : "hover:bg-muted/30"
} ${(stripeIndex ?? 0) % 2 === 1 && !isOpen ? "bg-muted/[0.08]" : ""} ${dragOver ? "ring-1 ring-inset ring-accent-brand bg-accent-brand/10" : ""}`}
>
<ChevronRight
className={`size-3.5 shrink-0 text-muted-foreground/60 transition-transform ${isOpen ? "rotate-90" : ""}`}
/>
{isOpen ? (
<FolderOpen className="size-4 shrink-0 text-accent-brand" />
) : (
<Folder className="size-4 shrink-0 text-muted-foreground/70" />
)}
{isRenaming ? (
<>
<input
autoFocus
value={editingFolderValue}
onChange={(e) => onEditingFolderValueChange(e.target.value)}
onBlur={async () => {
const newName = editingFolderValue.trim();
onEditingFolderNameChange(null);
if (newName && newName !== folder.name) {
await onRenameFolder(folder.name, newName);
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") e.currentTarget.blur();
if (e.key === "Escape") onEditingFolderNameChange(null);
}}
onClick={(e) => e.stopPropagation()}
className="text-[13px] font-bold bg-background border border-accent-brand/60 px-1 outline-none text-foreground min-w-0 flex-1"
/>
<button
onClick={(e) => {
e.stopPropagation();
onEditingFolderNameChange(null);
}}
className="text-muted-foreground hover:text-foreground shrink-0"
>
<X className="size-3" />
</button>
</>
) : (
<>
<span className="min-w-0 flex-1 truncate">
<span className="text-[13px] font-bold text-foreground tracking-tight">
{folder.name}
</span>
</span>
<span className="text-[10px] tabular-nums shrink-0 ml-1 px-1.5 py-[1px] bg-muted/70 text-muted-foreground/50">
{folder.children.length}
</span>
{canManage && (
<span className="flex items-center gap-0.5 ml-0.5 opacity-0 group-hover/folder:opacity-100 transition-opacity">
<button
type="button"
title={t("credentials.renameFolder")}
className={actionButtonClass}
onClick={(e) => {
e.stopPropagation();
onEditingFolderNameChange(folder.name);
onEditingFolderValueChange(folder.name);
}}
>
<Pencil className="size-3" />
</button>
{onDeleteFolder && (
<button
type="button"
title={t("credentials.deleteFolder")}
className={`${actionButtonClass} hover:text-destructive`}
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(folder.name);
}}
>
<Trash2 className="size-3" />
</button>
)}
</span>
)}
</>
)}
</div>
</div>
);
}
@@ -0,0 +1,126 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
getCredentialSidebarPreferences,
saveCredentialSidebarPreferences,
getUserPreferences,
} from "@/main-axios";
import {
defaultCredentialSidebarPreferences,
sanitizeCredentialSidebarPreferences,
type CredentialSidebarPreferences,
} from "@/types/credential-sidebar-preferences";
const SAVE_DEBOUNCE_MS = 500;
const LS_KEY = "credentialSidebarPreferences";
const SYNC_EVENT = "credentialSidebarPreferencesChanged";
function readCache(): CredentialSidebarPreferences | null {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return null;
return sanitizeCredentialSidebarPreferences(JSON.parse(raw));
} catch {
return null;
}
}
function writeCache(preferences: CredentialSidebarPreferences) {
try {
localStorage.setItem(LS_KEY, JSON.stringify(preferences));
} catch {
/* ignore */
}
}
/**
* Unified credential sidebar preferences (sort, filters, open folders,
* density, tag visibility, tray trigger), cached in localStorage for instant
* paint and synced to the backend so they follow the user across devices
* when storageMode is "cloud" -- same cache-then-fetch-then-debounced-save
* shape as useHostSidebarPreferences, but a fully independent parallel
* system (separate table, separate localStorage key, separate sync event),
* not a shared blob with hosts.
*
* Multiple components (CredentialsPanel, CustomizeCredentialsSidebarPanel)
* can call this hook at the same time. Each holds its own React state, so a
* write in one instance broadcasts a same-tab custom event that every other
* instance listens for to stay in sync.
*/
export function useCredentialSidebarPreferences() {
const [preferences, setPreferences] = useState<CredentialSidebarPreferences>(
() => readCache() ?? defaultCredentialSidebarPreferences(),
);
const [loaded, setLoaded] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let cancelled = false;
getCredentialSidebarPreferences()
.then((remote) => {
if (cancelled) return;
setPreferences(remote);
writeCache(remote);
})
.catch(() => {
/* keep cache/default */
})
.finally(() => {
if (!cancelled) setLoaded(true);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const handler = () => {
const cached = readCache();
if (cached) setPreferences(cached);
};
window.addEventListener(SYNC_EVENT, handler);
return () => window.removeEventListener(SYNC_EVENT, handler);
}, []);
const update = useCallback(
(
patch:
| Partial<CredentialSidebarPreferences>
| ((
prev: CredentialSidebarPreferences,
) => CredentialSidebarPreferences),
) => {
setPreferences((prev) => {
const next = sanitizeCredentialSidebarPreferences(
typeof patch === "function" ? patch(prev) : { ...prev, ...patch },
);
writeCache(next);
window.dispatchEvent(new Event(SYNC_EVENT));
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
getUserPreferences()
.then((prefs) => {
if (prefs.storageMode !== "cloud") return;
return saveCredentialSidebarPreferences(next);
})
.catch(() => {
/* best-effort; cache already holds it */
});
}, SAVE_DEBOUNCE_MS);
return next;
});
},
[],
);
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
return { preferences, update, loaded };
}
+2
View File
@@ -0,0 +1,2 @@
export { CredentialSidebarTree } from "./CredentialSidebarTree";
export { isFolder, type CredentialFolder } from "./visible-rows";
@@ -0,0 +1,66 @@
import type { Credential } from "@/types/ui-types";
/**
* Credential folders are explicitly flat (no nesting), unlike HostFolder --
* so this has no depth/path-string tree logic, just a name and a list of
* credentials. `children` matches HostFolder's shape for isFolder()
* structural parity, but never contains another CredentialFolder.
*/
export interface CredentialFolder {
name: string;
children: Credential[];
}
export function isFolder(
item: Credential | CredentialFolder,
): item is CredentialFolder {
return "children" in item;
}
export function credentialMatchesQuery(cred: Credential, query: string) {
return (
cred.name.toLowerCase().includes(query) ||
cred.username.toLowerCase().includes(query) ||
cred.tags?.some((t) => t.toLowerCase().includes(query))
);
}
export type VirtualRow = {
item: Credential | CredentialFolder;
depth: number;
};
/**
* Builds the flattened, currently-visible row list for the virtualizer.
* Folders are always depth 0, credentials always depth 1 -- there is no
* deeper nesting to walk, unlike hosts' recursive collectVisibleRows.
*/
export function collectVisibleRows(
folders: CredentialFolder[],
query: string,
openSet: Set<string>,
): VirtualRow[] {
const out: VirtualRow[] = [];
const q = query.trim().toLowerCase();
for (const folder of folders) {
const matchingCreds = q
? folder.children.filter((c) => credentialMatchesQuery(c, q))
: folder.children;
if (matchingCreds.length === 0) continue;
out.push({ item: folder, depth: 0 });
const isOpen = q ? true : openSet.has(folder.name);
if (isOpen) {
for (const cred of matchingCreds) {
out.push({ item: cred, depth: 1 });
}
}
}
return out;
}
export function collectAllFolderNames(folders: CredentialFolder[]): string[] {
return folders.map((f) => f.name);
}
+36 -16
View File
@@ -1,13 +1,7 @@
import type { Host, HostFolder } from "@/types/ui-types";
import type { SortKey } from "@/types/host-sidebar-preferences";
export type SortKey =
| "default"
| "name-asc"
| "name-desc"
| "ip-asc"
| "ip-desc"
| "status-online"
| "status-offline";
export type { SortKey };
const SORT_KEYS = new Set<SortKey>([
"default",
@@ -17,6 +11,7 @@ const SORT_KEYS = new Set<SortKey>([
"ip-desc",
"status-online",
"status-offline",
"manual",
]);
export function resolveHostSortPreferences(
@@ -49,23 +44,48 @@ export function sortHostTree(
const bIsFolder = isFolder(b);
if (aIsFolder && !bIsFolder) return -1;
if (!aIsFolder && bIsFolder) return 1;
if (aIsFolder && bIsFolder) return a.name.localeCompare(b.name);
if (aIsFolder && bIsFolder) {
if (key === "manual") {
const aOrder = a.sortOrder;
const bOrder = b.sortOrder;
if (aOrder == null && bOrder == null)
return a.name.localeCompare(b.name);
if (aOrder == null) return 1;
if (bOrder == null) return -1;
return aOrder - bOrder;
}
return a.name.localeCompare(b.name);
}
if (pinnedFirst && !!a.pin !== !!b.pin) return b.pin ? 1 : -1;
// The three branches above return for every combination involving a
// folder, so both sides are hosts from here on.
const hostA = a as Host;
const hostB = b as Host;
if (pinnedFirst && !!hostA.pin !== !!hostB.pin) return hostB.pin ? 1 : -1;
switch (key) {
case "name-asc":
return a.name.localeCompare(b.name);
return hostA.name.localeCompare(hostB.name);
case "name-desc":
return b.name.localeCompare(a.name);
return hostB.name.localeCompare(hostA.name);
case "ip-asc":
return a.ip.localeCompare(b.ip);
return hostA.ip.localeCompare(hostB.ip);
case "ip-desc":
return b.ip.localeCompare(a.ip);
return hostB.ip.localeCompare(hostA.ip);
case "status-online":
return Number(b.online) - Number(a.online);
return Number(hostB.online) - Number(hostA.online);
case "status-offline":
return Number(a.online) - Number(b.online);
return Number(hostA.online) - Number(hostB.online);
case "manual": {
const aOrder = hostA.sortOrder;
const bOrder = hostB.sortOrder;
if (aOrder == null && bOrder == null)
return hostA.name.localeCompare(hostB.name);
if (aOrder == null) return 1;
if (bOrder == null) return -1;
return aOrder - bOrder;
}
case "default":
return 0;
}
+3
View File
@@ -33,7 +33,9 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
enableTunnel: true,
enableDocker: true,
enableProxmox: false,
enableProxmoxStats: false,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
enableSsh: true,
enableRdp: false,
enableVnc: false,
@@ -68,6 +70,7 @@ export function quickConnectHostToPayload(host: Host): SSHHostData {
enableDocker: host.enableDocker,
enableProxmox: host.enableProxmox,
enableTmuxMonitor: host.enableTmuxMonitor,
enableTerminalToolbar: host.enableTerminalToolbar,
showTerminalInSidebar: true,
showFileManagerInSidebar: true,
showTunnelInSidebar: true,
@@ -0,0 +1,23 @@
export interface CredentialOption {
id: string;
name: string;
username: string;
}
export function toCredentialOption(
credential: Record<string, unknown>,
): CredentialOption | null {
if (
(typeof credential.id !== "number" && typeof credential.id !== "string") ||
typeof credential.name !== "string"
) {
return null;
}
return {
id: String(credential.id),
name: credential.name,
username:
typeof credential.username === "string" ? credential.username : "",
};
}
+233
View File
@@ -0,0 +1,233 @@
import {
Boxes,
Braces,
Clock,
Fingerprint,
Hammer,
KeyRound,
LayoutPanelLeft,
LayoutTemplate,
Network,
Play,
Plug,
ScrollText,
Server,
Settings,
Sparkles,
TerminalSquare,
Usb,
User,
Workflow,
Zap,
type LucideIcon,
} from "lucide-react";
import { isElectron } from "@/lib/electron";
/**
* The one list of navigation destinations.
*
* This used to be duplicated in four places -- AppRail's button array, the
* visibility toggles in UserProfilePanel, AppShell's sidebar title map, and
* MobileBottomBar's own primary/more lists -- which drifted: half the sidebar
* titles were hardcoded English, the alerts entry had no visibility toggle,
* and the mobile bar ignored hidden tabs entirely. Everything now derives from
* here, so adding a destination is a single edit.
*/
export interface RailItemDef {
/** Matches RailView, or a TabType for entries that open a tab instead. */
id: string;
icon: LucideIcon;
/** i18n key; every label goes through t() so nothing is hardcoded English. */
labelKey: string;
/** Tab-opening entries (network_graph) rather than sidebar panels. */
kind?: "tab";
/** Always-available destinations that users cannot hide. */
alwaysVisible?: boolean;
/** Renders a separator after this item in the rail. */
separatorAfter?: boolean;
/** Shown on the mobile bottom bar's primary row rather than its More menu. */
mobilePrimary?: boolean;
/**
* Can also open as a full-width tab in the main area, via ctrl/middle-click
* or the rail context menu. The id doubles as the TabType.
*/
promotable?: boolean;
/**
* Can be opened in the right dock. Reference panels only -- editors stay in
* the left sidebar, which is the only dock that widens for them.
*/
rightDockable?: boolean;
/** Desktop app only. Hidden in the browser build, including its toggle. */
electronOnly?: boolean;
}
export const RAIL_ITEMS: RailItemDef[] = [
{ id: "hosts", icon: Server, labelKey: "nav.hosts", mobilePrimary: true },
{
id: "credentials",
icon: KeyRound,
labelKey: "nav.credentials",
separatorAfter: true,
},
{
id: "termix-id",
icon: Fingerprint,
labelKey: "nav.termixId",
separatorAfter: true,
promotable: true,
},
{
id: "connections",
icon: Plug,
labelKey: "nav.connections",
separatorAfter: true,
rightDockable: true,
},
{
id: "quick-connect",
icon: Zap,
labelKey: "nav.quickConnect",
separatorAfter: true,
mobilePrimary: true,
},
{ id: "serial", icon: Usb, labelKey: "nav.serial", separatorAfter: true },
{
id: "ssh-tools",
icon: Hammer,
labelKey: "nav.sshTools",
separatorAfter: true,
mobilePrimary: true,
promotable: true,
rightDockable: true,
},
{
id: "snippets",
icon: Play,
labelKey: "nav.snippets",
separatorAfter: true,
mobilePrimary: true,
promotable: true,
rightDockable: true,
},
{
id: "macros",
icon: Braces,
labelKey: "nav.macros",
separatorAfter: true,
promotable: true,
rightDockable: true,
},
{ id: "fleets", icon: Boxes, labelKey: "nav.fleets", separatorAfter: true },
{
id: "automations",
icon: Workflow,
labelKey: "nav.automations",
separatorAfter: true,
promotable: true,
},
{
id: "ai",
icon: Sparkles,
labelKey: "nav.ai",
separatorAfter: true,
promotable: true,
rightDockable: true,
},
{
id: "history",
icon: Clock,
labelKey: "nav.history",
separatorAfter: true,
promotable: true,
rightDockable: true,
},
{
id: "session-logs",
icon: ScrollText,
labelKey: "nav.sessionLogs",
separatorAfter: true,
promotable: true,
rightDockable: true,
},
{
id: "split-screen",
icon: LayoutPanelLeft,
labelKey: "nav.splitScreen",
separatorAfter: true,
},
{
id: "workspaces",
icon: LayoutTemplate,
labelKey: "nav.workspaces",
separatorAfter: true,
},
{
id: "local-terminal",
icon: TerminalSquare,
labelKey: "nav.localTerminal",
kind: "tab",
separatorAfter: true,
electronOnly: true,
},
{
id: "network_graph",
icon: Network,
labelKey: "nav.networkGraph",
kind: "tab",
separatorAfter: true,
},
];
/**
* Rail items available in the current build. Electron-only destinations are
* dropped in the browser build so they never reach the rail, the mobile bar,
* or the visibility toggles.
*/
export function visibleRailItems(): RailItemDef[] {
const electron = isElectron();
return RAIL_ITEMS.filter((item) => !item.electronOnly || electron);
}
/**
* Destinations that live outside the rail's hideable list but still need a
* title and a mobile entry.
*/
export const RAIL_UTILITY_ITEMS: RailItemDef[] = [
{
id: "alerts",
icon: Plug,
labelKey: "nav.alerts",
promotable: true,
rightDockable: true,
},
{ id: "user-profile", icon: User, labelKey: "nav.userProfile" },
{ id: "admin-settings", icon: Settings, labelKey: "nav.admin" },
];
/** Ids that may be opened in the right dock. */
export const RIGHT_DOCKABLE_IDS = [...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
.filter((item) => item.rightDockable)
.map((item) => item.id);
/** Ids that may be opened as a full-width tab. */
export const PROMOTABLE_IDS = [...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
.filter((item) => item.promotable)
.map((item) => item.id);
/** Ids a user is allowed to hide, mirroring HideableRailView. */
export const HIDEABLE_RAIL_IDS = RAIL_ITEMS.filter(
(item) => !item.alwaysVisible,
).map((item) => item.id);
const LABEL_KEYS: Record<string, string> = Object.fromEntries(
[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS].map((item) => [
item.id,
item.labelKey,
]),
);
/** Translated label for any rail destination. */
export function railItemLabel(id: string, t: (key: string) => string): string {
const key = LABEL_KEYS[id];
return key ? t(key) : id;
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Manual drag-to-reorder position math, shared by host and folder reordering.
* Sparse integers (gaps of 1000) mean most drops only rewrite the moved
* item's own sortOrder -- inserting between two neighbors is just the
* midpoint between their values. Renumbering the whole sibling group only
* happens when a gap runs out (two adjacent siblings differ by 1).
*/
const GAP = 1000;
export interface Orderable {
sortOrder?: number | null;
}
/**
* Computes the sortOrder for an item dropped between `before` and `after`
* (either may be absent for a drop at the start/end of the list). Returns
* null when the gap between neighbors has been exhausted and the caller
* should renumber the whole sibling group instead via renumberSiblings.
*/
export function computeDropSortOrder(
before: Orderable | null,
after: Orderable | null,
): number | null {
const beforeOrder = before?.sortOrder ?? null;
const afterOrder = after?.sortOrder ?? null;
if (beforeOrder == null && afterOrder == null) return GAP;
if (beforeOrder == null) return (afterOrder as number) - GAP;
if (afterOrder == null) return beforeOrder + GAP;
const mid = Math.floor((beforeOrder + afterOrder) / 2);
if (mid <= beforeOrder || mid >= afterOrder) return null;
return mid;
}
/**
* Assigns fresh, evenly-spaced sortOrder values (0, GAP, 2*GAP, ...) to an
* ordered list of sibling ids, for when computeDropSortOrder signals the
* available gap is exhausted.
*/
export function renumberSiblings<T extends { id: string | number }>(
orderedIds: T[],
): { id: string | number; sortOrder: number }[] {
return orderedIds.map((item, index) => ({
id: item.id,
sortOrder: index * GAP,
}));
}
export type ReorderKind = "host" | "folder" | "cred";
export interface ReorderRow {
/** Stable key, e.g. "host:12" or "folder:Prod / Web". */
key: string;
/** Identifies the row's parent so siblings can be scoped to one group. */
parentKey: string;
sortOrder?: number | null;
}
export interface ReorderPlan {
/** Rows to write, already carrying their new sortOrder. */
positions: { key: string; sortOrder: number }[];
/** New parent for the dragged row, when the drop crossed groups. */
movedTo: string | null;
}
/**
* Resolves a drop into the exact writes it implies.
*
* Siblings are scoped to the drop target's parent, not to every row of the
* same type -- dropping onto a row in another folder has to compare against
* that folder's own neighbors, and reports the parent change so the caller
* can persist the move alongside the new position.
*
* Returns null when the drop is a no-op (unknown rows, or dropping an item
* back onto the slot it already occupies).
*/
export function planReorder(
rows: ReorderRow[],
draggedKey: string,
targetKey: string,
position: "before" | "after",
): ReorderPlan | null {
if (draggedKey === targetKey) return null;
const dragged = rows.find((r) => r.key === draggedKey);
const target = rows.find((r) => r.key === targetKey);
if (!dragged || !target) return null;
const siblings = rows.filter((r) => r.parentKey === target.parentKey);
const targetIndex = siblings.findIndex((r) => r.key === targetKey);
if (targetIndex === -1) return null;
const before =
position === "before" ? (siblings[targetIndex - 1] ?? null) : target;
const after =
position === "before" ? target : (siblings[targetIndex + 1] ?? null);
// Already sitting exactly where the drop would put it.
if (before?.key === draggedKey || after?.key === draggedKey) return null;
const movedTo =
dragged.parentKey === target.parentKey ? null : target.parentKey;
// Hosts start life with sortOrder null, and null sorts last in manual
// mode. Writing a single value for the moved row against null neighbours
// therefore says nothing about where it landed -- the group has to be
// numbered as a whole before any position is meaningful. A cross-folder
// drop renumbers for the same reason: the dragged row arrives carrying a
// sortOrder from its old folder, which means nothing in this one.
const needsFullRenumber =
movedTo !== null ||
siblings.some((r) => r.key !== draggedKey && r.sortOrder == null);
const sortOrder = needsFullRenumber
? null
: computeDropSortOrder(
before && before.key !== draggedKey ? before : null,
after && after.key !== draggedKey ? after : null,
);
if (sortOrder !== null) {
return { positions: [{ key: draggedKey, sortOrder }], movedTo };
}
// Gap exhausted: renumber the whole destination group with the dragged row
// spliced into its new slot.
const ordered = siblings.filter((r) => r.key !== draggedKey);
const targetPos = ordered.findIndex((r) => r.key === targetKey);
const insertAt = position === "before" ? targetPos : targetPos + 1;
ordered.splice(insertAt, 0, dragged);
return {
positions: renumberSiblings(ordered.map((r) => ({ id: r.key }))).map(
(r) => ({ key: String(r.id), sortOrder: r.sortOrder }),
),
movedTo,
};
}
@@ -0,0 +1,71 @@
import { useTranslation } from "react-i18next";
import { FolderOpen, Pencil, Share2, Trash2 } from "lucide-react";
import type { HostFolder } from "@/types/ui-types";
export function FolderActions({
folder,
onOpenAllSessions,
onShareFolder,
onManageFolder,
onDeleteFolder,
}: {
folder: HostFolder;
onOpenAllSessions: (folder: HostFolder) => void;
onShareFolder?: (folder: HostFolder) => void;
onManageFolder: (folder: HostFolder) => void;
onDeleteFolder: (folder: HostFolder) => void;
}) {
const { t } = useTranslation();
const actionButtonClass =
"flex items-center justify-center size-6 text-muted-foreground/60 hover:text-foreground hover:bg-background/80 transition-colors";
return (
<span className="flex items-center gap-0.5 ml-0.5 opacity-0 group-hover/folder:opacity-100 transition-opacity">
<button
type="button"
title={t("hosts.openAllSessions")}
className={actionButtonClass}
onClick={(e) => {
e.stopPropagation();
onOpenAllSessions(folder);
}}
>
<FolderOpen className="size-3" />
</button>
{onShareFolder && (
<button
type="button"
title={t("hosts.shareFolder")}
className={actionButtonClass}
onClick={(e) => {
e.stopPropagation();
onShareFolder(folder);
}}
>
<Share2 className="size-3" />
</button>
)}
<button
type="button"
title={t("hosts.editFolder")}
className={actionButtonClass}
onClick={(e) => {
e.stopPropagation();
onManageFolder(folder);
}}
>
<Pencil className="size-3" />
</button>
<button
type="button"
title={t("hosts.deleteFolder")}
className={`${actionButtonClass} hover:text-destructive`}
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(folder);
}}
>
<Trash2 className="size-3" />
</button>
</span>
);
}
@@ -0,0 +1,385 @@
/* eslint-disable react-refresh/only-export-components */
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, Check, GripVertical } from "lucide-react";
import type { Host, HostFolder, TabType } from "@/types/ui-types";
import type {
HostDensity,
HostTrayTrigger,
} from "@/types/host-sidebar-preferences";
import { FolderIconEl } from "@/components/folder-style";
import { useServerStatus } from "@/lib/ServerStatusContext";
import { HostItem, statusCheckEnabled } from "../HostItem/HostItem";
import { isFolder, folderHasMatch, collectAllHosts } from "../visible-rows";
import { FolderActions } from "./FolderActions";
export function folderHostCount(folder: HostFolder): {
total: number;
online: number;
} {
let total = 0,
online = 0;
for (const child of folder.children) {
if (isFolder(child)) {
const c = folderHostCount(child);
total += c.total;
online += c.online;
} else {
total++;
if (child.online) online++;
}
}
return { total, online };
}
export function FolderItem({
folder,
depth = 0,
onOpenTab,
onEditHost,
onShareHost,
onDeleteHost,
onDuplicateHost,
onProxmoxDiscover,
query = "",
stripeMap,
openFolders,
onToggleFolder,
selectionMode,
selectedHostIds,
onToggleSelect,
onToggleSelectFolder,
openMenuHostId,
onMenuOpenChange,
openTrayHostId,
onTrayOpenChange,
onManageFolder,
onDeleteFolder,
onOpenAllSessions,
onShareFolder,
onMoveHostsToFolder,
draggedHostIds,
onDragHostStart,
onDragEnd,
/** When true, only render the folder header (children come from the virtual list). */
flat = false,
stripeIndex: stripeIndexProp,
density = "comfortable",
trayTrigger = "hover",
showTags = true,
openOnDoubleClick = false,
arrangeMode = false,
isDragging = false,
onReorderDrop,
onFolderDragStart,
onFolderDragEnd,
isReorderHovered = false,
reorderHoverEdge = null,
onReorderHoverChange,
}: {
folder: HostFolder;
depth?: number;
onOpenTab: (host: Host, type: TabType) => void;
onEditHost?: (host: Host) => void;
onShareHost?: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onProxmoxDiscover?: (host: Host) => void;
query?: string;
stripeMap?: Map<Host | HostFolder, number>;
openFolders: Set<string>;
onToggleFolder: (name: string) => void;
selectionMode: boolean;
selectedHostIds: Set<string>;
onToggleSelect: (id: string) => void;
onToggleSelectFolder: (folder: HostFolder) => void;
openMenuHostId: string | null;
onMenuOpenChange: (hostId: string | null) => void;
openTrayHostId: string | null;
onTrayOpenChange: (hostId: string | null) => void;
onManageFolder: (folder: HostFolder) => void;
onDeleteFolder: (folder: HostFolder) => void;
onOpenAllSessions: (folder: HostFolder) => void;
onShareFolder?: (folder: HostFolder) => void;
onMoveHostsToFolder: (hostIds: string[], targetPath: string) => void;
draggedHostIds: string[] | null;
onDragHostStart: (hostId: string) => void;
onDragEnd: () => void;
flat?: boolean;
stripeIndex?: number;
density?: HostDensity;
trayTrigger?: HostTrayTrigger;
showTags?: boolean;
openOnDoubleClick?: boolean;
/** When true (rearranging unlocked), the header can be dragged and its
* top/bottom edges become reorder drop zones. The middle still accepts
* hosts dropped into the folder. */
arrangeMode?: boolean;
/** True while this folder is the one being dragged. */
isDragging?: boolean;
onReorderDrop?: (targetKey: string, position: "before" | "after") => void;
onFolderDragStart?: (folderPath: string) => void;
onFolderDragEnd?: () => void;
/** Whether THIS folder header is the current reorder drop target -- see
* HostItem's identical prop for why this is lifted rather than local. */
isReorderHovered?: boolean;
reorderHoverEdge?: "before" | "after" | null;
onReorderHoverChange?: (edge: "before" | "after" | null) => void;
}) {
const { t } = useTranslation();
const { getStatus, initialLoadComplete } = useServerStatus();
const { total } = folderHostCount(folder);
const online = initialLoadComplete
? collectAllHosts(folder.children).filter(
(h) => statusCheckEnabled(h) && getStatus(Number(h.id)) === "online",
).length
: folderHostCount(folder).online;
const [dragOver, setDragOver] = useState(false);
const reorderEdge = isReorderHovered ? reorderHoverEdge : null;
if (query && !folderHasMatch(folder, query)) return null;
const folderPath = folder.path ?? folder.name;
const isOpen = query ? true : openFolders.has(folderPath);
const stripeIndex = stripeIndexProp ?? stripeMap?.get(folder) ?? 0;
// Synthetic group headers (group-by tag/status/etc.) are not real folders, so
// they can't be edited, deleted, or used as drop targets.
const isGroup = folderPath.startsWith("__group__:");
// Nested folders show their parent path as a muted breadcrumb so depth stays
// legible even when a folder is reached via search auto-expand rather than
// by manually opening every ancestor.
const pathSegments = isGroup ? [] : folderPath.split(" / ");
const breadcrumb =
pathSegments.length > 1 ? pathSegments.slice(0, -1).join(" / ") : null;
const folderHosts = collectAllHosts(folder.children);
const folderSelected =
folderHosts.length > 0 &&
folderHosts.every((h) => selectedHostIds.has(h.id));
const canDragFolder = arrangeMode && !isGroup;
// Hosts land IN the folder; the header's own top/bottom edges reorder it
// among its siblings.
const acceptsHostDrop = !!draggedHostIds && !isGroup;
return (
<div
className="relative"
style={depth > 0 ? { paddingLeft: depth * 12 } : undefined}
>
<div className="relative">
<button
draggable={canDragFolder}
onDragStart={(e) => {
if (!canDragFolder) return;
e.dataTransfer.effectAllowed = "move";
onFolderDragStart?.(folderPath);
}}
onDragEnd={() => {
setDragOver(false);
onReorderHoverChange?.(null);
onFolderDragEnd?.();
}}
// The drop zones live on the header, not on the wrapper around the
// folder's whole subtree -- measuring the edges against the full
// expanded subtree made the before/after split land hundreds of
// pixels away from the header the user was actually pointing at.
onDragOver={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const offset = e.clientY - rect.top;
const band = Math.min(8, rect.height * 0.3);
const wantsInto =
acceptsHostDrop && offset > band && offset < rect.height - band;
if (wantsInto) {
e.preventDefault();
e.stopPropagation();
onReorderHoverChange?.(null);
setDragOver(true);
return;
}
if (arrangeMode && onReorderDrop && !isGroup) {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
onReorderHoverChange?.(
offset < rect.height / 2 ? "before" : "after",
);
}
}}
onDragLeave={(e) => {
if (e.currentTarget === e.target) setDragOver(false);
}}
onDrop={(e) => {
if (dragOver && acceptsHostDrop && draggedHostIds) {
e.preventDefault();
e.stopPropagation();
setDragOver(false);
onMoveHostsToFolder(draggedHostIds, folderPath);
return;
}
if (arrangeMode && onReorderDrop && !isGroup && reorderEdge) {
e.preventDefault();
e.stopPropagation();
onReorderDrop(`folder:${folderPath}`, reorderEdge);
onReorderHoverChange?.(null);
}
}}
onClick={() => !query && onToggleFolder(folderPath)}
className={`group/folder flex items-center gap-2 w-full pl-2.5 pr-2 py-1.5 transition-colors text-left ${
canDragFolder
? "cursor-grab active:cursor-grabbing"
: "cursor-pointer"
} ${
isOpen ? "bg-muted/40" : "hover:bg-muted/30"
} ${stripeIndex % 2 === 1 && !isOpen ? "bg-muted/[0.08]" : ""} ${dragOver ? "ring-1 ring-inset ring-accent-brand bg-accent-brand/10" : ""} ${isDragging ? "opacity-40" : ""}`}
>
{canDragFolder && (
<GripVertical className="size-3 shrink-0 text-muted-foreground/35 group-hover/folder:text-muted-foreground/70 transition-colors" />
)}
<ChevronRight
className={`size-3.5 shrink-0 text-muted-foreground/60 transition-transform ${isOpen ? "rotate-90" : ""}`}
/>
{selectionMode && !isGroup && folderHosts.length > 0 && (
<div
role="checkbox"
aria-checked={folderSelected}
title={
folderSelected
? t("hosts.deselectFolder")
: t("hosts.selectFolder")
}
onClick={(e) => {
e.stopPropagation();
onToggleSelectFolder(folder);
}}
className={`size-3.5 border-2 flex items-center justify-center shrink-0 transition-colors ${folderSelected ? "border-accent-brand bg-accent-brand" : "border-border bg-background"}`}
>
{folderSelected && <Check className="size-2 text-background" />}
</div>
)}
<FolderIconEl
icon={folder.icon ?? "folder"}
className={`size-4 shrink-0 ${folder.color ? "" : isOpen ? "text-accent-brand" : "text-muted-foreground/70"}`}
style={folder.color ? { color: folder.color } : undefined}
/>
{
<>
<span className="min-w-0 flex-1 truncate">
{breadcrumb && (
<span className="text-[10px] text-muted-foreground/40 truncate mr-1">
{breadcrumb} /
</span>
)}
<span className="text-[13px] font-bold text-foreground tracking-tight">
{folder.name}
</span>
</span>
<span className="flex items-center gap-1 text-[10px] tabular-nums shrink-0 ml-1 px-1.5 py-[1px] bg-muted/70">
{online > 0 && (
<span className="text-accent-brand font-semibold">
{online}
</span>
)}
<span className="text-muted-foreground/50">/{total}</span>
</span>
{!isGroup && (
<FolderActions
folder={folder}
onOpenAllSessions={onOpenAllSessions}
onShareFolder={onShareFolder}
onManageFolder={onManageFolder}
onDeleteFolder={onDeleteFolder}
/>
)}
</>
}
</button>
</div>
{!flat && isOpen && (
<div className="border-l border-border/50 ml-[27px]">
{folder.children.map((child, i) =>
isFolder(child) ? (
<FolderItem
key={i}
folder={child}
depth={depth + 1}
onOpenTab={onOpenTab}
onEditHost={onEditHost}
onShareHost={onShareHost}
onDeleteHost={onDeleteHost}
onDuplicateHost={onDuplicateHost}
onProxmoxDiscover={onProxmoxDiscover}
query={query}
stripeMap={stripeMap}
openFolders={openFolders}
onToggleFolder={onToggleFolder}
selectionMode={selectionMode}
selectedHostIds={selectedHostIds}
onToggleSelect={onToggleSelect}
onToggleSelectFolder={onToggleSelectFolder}
openMenuHostId={openMenuHostId}
onMenuOpenChange={onMenuOpenChange}
openTrayHostId={openTrayHostId}
onTrayOpenChange={onTrayOpenChange}
onManageFolder={onManageFolder}
onDeleteFolder={onDeleteFolder}
onOpenAllSessions={onOpenAllSessions}
onShareFolder={onShareFolder}
onMoveHostsToFolder={onMoveHostsToFolder}
draggedHostIds={draggedHostIds}
onDragHostStart={onDragHostStart}
onDragEnd={onDragEnd}
density={density}
trayTrigger={trayTrigger}
showTags={showTags}
openOnDoubleClick={openOnDoubleClick}
arrangeMode={arrangeMode}
onReorderDrop={onReorderDrop}
onFolderDragStart={onFolderDragStart}
onFolderDragEnd={onFolderDragEnd}
/>
) : (
<HostItem
key={i}
host={child}
onOpenTab={(t) => onOpenTab(child, t)}
onEditHost={onEditHost ? () => onEditHost(child) : undefined}
onShareHost={onShareHost ? () => onShareHost(child) : undefined}
onProxmoxDiscover={
onProxmoxDiscover ? () => onProxmoxDiscover(child) : undefined
}
onDelete={() => onDeleteHost(child)}
onDuplicate={() => onDuplicateHost(child)}
query={query}
stripeIndex={stripeMap?.get(child) ?? 0}
selectionMode={selectionMode}
selected={selectedHostIds.has(child.id)}
onToggleSelect={() => onToggleSelect(child.id)}
isMenuOpen={openMenuHostId === child.id}
onMenuOpenChange={(open) =>
onMenuOpenChange(open ? child.id : null)
}
isTrayOpen={openTrayHostId === child.id}
onTrayOpenChange={(open) =>
onTrayOpenChange(open ? child.id : null)
}
onDragStart={() => onDragHostStart(child.id)}
onDragEnd={onDragEnd}
density={density}
trayTrigger={trayTrigger}
showTags={showTags}
openOnDoubleClick={openOnDoubleClick}
arrangeMode={arrangeMode}
onReorderDrop={
onReorderDrop
? (position) => onReorderDrop(`host:${child.id}`, position)
: undefined
}
/>
),
)}
</div>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,125 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
getHostSidebarPreferences,
saveHostSidebarPreferences,
getUserPreferences,
} from "@/main-axios";
import {
defaultHostSidebarPreferences,
sanitizeHostSidebarPreferences,
type HostSidebarPreferences,
} from "@/types/host-sidebar-preferences";
const SAVE_DEBOUNCE_MS = 500;
const LS_KEY = "hostSidebarPreferences";
const SYNC_EVENT = "hostSidebarPreferencesChanged";
function readCache(): HostSidebarPreferences | null {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return null;
return sanitizeHostSidebarPreferences(JSON.parse(raw));
} catch {
return null;
}
}
function writeCache(preferences: HostSidebarPreferences) {
try {
localStorage.setItem(LS_KEY, JSON.stringify(preferences));
} catch {
/* ignore */
}
}
/**
* Unified host sidebar preferences (sort, group, filters, open folders,
* density, tag visibility, tray trigger, status color scheme), cached in
* localStorage for instant paint and synced to the backend so they follow
* the user across devices when storageMode is "cloud" -- same
* cache-then-fetch-then-debounced-save shape as useHostMetricsPreferences,
* but per-user rather than per-host, and gated on storageMode the way the
* older per-preference localStorage toggles were.
*
* Multiple components (HostsPanel, CustomizeSidebarPanel, UserProfilePanel's
* resetToDefaults) can call this hook at the same time. Each holds its own
* React state, so a write in one instance broadcasts a same-tab custom event
* (mirroring the showHostTagsChanged-style events the older per-preference
* toggles used) that every other instance listens for to stay in sync.
*/
export function useHostSidebarPreferences() {
const [preferences, setPreferences] = useState<HostSidebarPreferences>(
() => readCache() ?? defaultHostSidebarPreferences(),
);
const [loaded, setLoaded] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
let cancelled = false;
getHostSidebarPreferences()
.then((remote) => {
if (cancelled) return;
setPreferences(remote);
writeCache(remote);
})
.catch(() => {
/* keep cache/default */
})
.finally(() => {
if (!cancelled) setLoaded(true);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
const handler = () => {
const cached = readCache();
if (cached) setPreferences(cached);
};
window.addEventListener(SYNC_EVENT, handler);
return () => window.removeEventListener(SYNC_EVENT, handler);
}, []);
const update = useCallback(
(
patch:
| Partial<HostSidebarPreferences>
| ((prev: HostSidebarPreferences) => HostSidebarPreferences),
) => {
setPreferences((prev) => {
const next = sanitizeHostSidebarPreferences(
typeof patch === "function" ? patch(prev) : { ...prev, ...patch },
);
writeCache(next);
window.dispatchEvent(new Event(SYNC_EVENT));
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
getUserPreferences()
.then((prefs) => {
if (prefs.storageMode !== "cloud") return;
return saveHostSidebarPreferences(next);
})
.catch(() => {
/* best-effort; cache already holds it */
});
}, SAVE_DEBOUNCE_MS);
return next;
});
},
[],
);
useEffect(() => {
return () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
};
}, []);
return { preferences, update, loaded };
}
@@ -0,0 +1,14 @@
import { useState } from "react";
/** Drag-in-progress host ids and root drop-zone hover state for the tree. */
export function useSidebarDragState() {
const [draggedHostIds, setDraggedHostIds] = useState<string[] | null>(null);
const [rootDragOver, setRootDragOver] = useState(false);
return {
draggedHostIds,
setDraggedHostIds,
rootDragOver,
setRootDragOver,
};
}
@@ -0,0 +1,53 @@
import { useState } from "react";
/** Selected-hosts and open-menu/tray id state shared across the tree's rows. */
export function useSidebarSelection() {
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(
new Set(),
);
const [openMenuHostId, setOpenMenuHostId] = useState<string | null>(null);
const [openTrayHostId, setOpenTrayHostId] = useState<string | null>(null);
// Hover-mode tray expansion has to live in state, not just CSS: rows are
// absolutely positioned at virtualizer-computed heights, so a row that grows
// on hover would overlap the row below unless the height is reserved too.
const [hoveredHostId, setHoveredHostId] = useState<string | null>(null);
function toggleSelect(id: string) {
setSelectedHostIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
/** Selects every host id, or deselects them if they're all already selected. */
function toggleSelectMany(ids: string[]) {
setSelectedHostIds((prev) => {
const allSelected = ids.every((id) => prev.has(id));
const next = new Set(prev);
if (allSelected) {
for (const id of ids) next.delete(id);
} else {
for (const id of ids) next.add(id);
}
return next;
});
}
return {
selectedHostIds,
setSelectedHostIds,
openMenuHostId,
setOpenMenuHostId,
openTrayHostId,
setOpenTrayHostId,
hoveredHostId,
setHoveredHostId,
toggleSelect,
toggleSelectMany,
};
}
+4
View File
@@ -0,0 +1,4 @@
export { isFolder } from "./visible-rows";
export { HostItem } from "./HostItem/HostItem";
export { FolderItem } from "./FolderItem/FolderItem";
export { SidebarTree } from "./SidebarTree";
+192
View File
@@ -0,0 +1,192 @@
import type { Host, HostFolder } from "@/types/ui-types";
export function isFolder(item: Host | HostFolder): item is HostFolder {
return "children" in item;
}
export function hostMatchesQuery(host: Host, query: string) {
return (
host.name.toLowerCase().includes(query) ||
host.ip.toLowerCase().includes(query) ||
host.username.toLowerCase().includes(query) ||
host.tags?.some((t) => t.toLowerCase().includes(query))
);
}
/** Key used in the openFolders/openSet expand-state Set for a host's own children row. */
export function hostExpandKey(host: Host): string {
return `host:${host.id}`;
}
function hostHasMatch(host: Host, query: string): boolean {
if (hostMatchesQuery(host, query)) return true;
return (host.childHosts ?? []).some((child) => hostHasMatch(child, query));
}
export function folderHasMatch(folder: HostFolder, query: string): boolean {
for (const child of folder.children) {
if (isFolder(child)) {
if (folderHasMatch(child, query)) return true;
} else {
if (hostHasMatch(child, query)) return true;
}
}
return false;
}
export type VirtualRow = { item: Host | HostFolder; depth: number };
function collectVisibleHostRows(
host: Host,
query: string,
closedHostParents: Set<string>,
out: VirtualRow[],
depth: number,
): void {
if (!query || hostMatchesQuery(host, query) || hostHasMatch(host, query)) {
out.push({ item: host, depth });
} else {
return;
}
const childHosts = host.childHosts ?? [];
if (childHosts.length === 0) return;
// Sub-host parents default to expanded (opposite of folders): a host that
// just got reparented shouldn't seem to disappear because its new parent
// row starts closed. closedHostParents tracks the opposite of openFolders
// -- parents the user has explicitly collapsed.
const isOpen = query ? true : !closedHostParents.has(hostExpandKey(host));
if (!isOpen) return;
for (const child of childHosts) {
collectVisibleHostRows(child, query, closedHostParents, out, depth + 1);
}
}
export function collectVisibleRows(
children: (Host | HostFolder)[],
query: string,
openSet: Set<string>,
out: VirtualRow[] = [],
depth = 0,
closedHostParents: Set<string> = new Set(),
): VirtualRow[] {
for (const child of children) {
if (isFolder(child)) {
const visible = query ? folderHasMatch(child, query) : true;
if (!visible) continue;
out.push({ item: child, depth });
const childOpen = query ? true : openSet.has(child.path ?? child.name);
if (childOpen)
collectVisibleRows(
child.children,
query,
openSet,
out,
depth + 1,
closedHostParents,
);
} else {
collectVisibleHostRows(child, query, closedHostParents, out, depth);
}
}
return out;
}
export function collectAllHosts(children: (Host | HostFolder)[]): Host[] {
const out: Host[] = [];
for (const child of children) {
if (isFolder(child)) {
out.push(...collectAllHosts(child.children));
} else {
out.push(child);
if (child.childHosts && child.childHosts.length > 0) {
out.push(...collectAllHosts(child.childHosts));
}
}
}
return out;
}
// Open/close state and folder assignment are both keyed by the full " / " path,
// so two folders that share a leaf name don't collapse together. Synthetic group
// headers (group-by views) are excluded from the assignable-folder list.
export function collectAllFolderPaths(
children: (Host | HostFolder)[],
): string[] {
const paths = new Set<string>();
for (const child of children) {
if (isFolder(child)) {
const path = child.path ?? child.name;
if (!path.startsWith("__group__:")) paths.add(path);
for (const p of collectAllFolderPaths(child.children)) paths.add(p);
}
}
return Array.from(paths).sort((a, b) => a.localeCompare(b));
}
/** Sentinel parent for rows sitting at the tree root (no folder). */
export const ROOT_PARENT = "__root__";
export function rowKey(item: Host | HostFolder): string {
return isFolder(item)
? `folder:${item.path ?? item.name}`
: `host:${item.id}`;
}
export function rowKind(key: string): string {
return key.slice(0, key.indexOf(":"));
}
/**
* Flattens the visible rows into the parent-scoped shape planReorder needs.
*
* Parent is derived from the row's own data rather than from its position in
* the flattened list: a folder's parent is its path minus the last segment,
* a host's is its folder (or its parent host when nested as a sub-host).
* Scoping by parent is what keeps a drop comparing against the neighbours it
* was actually dropped between.
*/
export function buildReorderRows(
rows: { item: Host | HostFolder }[],
): { key: string; parentKey: string; sortOrder?: number | null }[] {
return rows.map(({ item }) => {
if (isFolder(item)) {
const path = item.path ?? item.name;
const cut = path.lastIndexOf(" / ");
return {
key: rowKey(item),
parentKey: cut === -1 ? ROOT_PARENT : `folder:${path.slice(0, cut)}`,
sortOrder: item.sortOrder,
};
}
const parentKey = item.parentHostId
? `host:${item.parentHostId}`
: item.folder
? `folder:${item.folder}`
: ROOT_PARENT;
return { key: rowKey(item), parentKey, sortOrder: item.sortOrder };
});
}
/**
* Every orderable row in the tree, visible or not, in tree order.
*
* Reorder math must run against the complete sibling set: visibleRows omits
* anything inside a collapsed folder, so planning a drop off it compared the
* dragged row against a partial group and produced a position that only
* settled correctly after a second drop.
*/
export function collectOrderableRows(
children: (Host | HostFolder)[],
out: { item: Host | HostFolder }[] = [],
): { item: Host | HostFolder }[] {
for (const child of children) {
if (isFolder(child)) {
out.push({ item: child });
collectOrderableRows(child.children, out);
} else {
out.push({ item: child });
if (child.childHosts?.length) collectOrderableRows(child.childHosts, out);
}
}
return out;
}
+52
View File
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useState } from "react";
/**
* Drag-to-rearrange lock for a sidebar tree, kept per-device in
* localStorage rather than in the synced sidebar-preferences blob.
*
* It deliberately does NOT live in HostSidebarPreferences /
* CredentialSidebarPreferences: those are fetched from the server on every
* mount and the response replaces local state wholesale, so a value the
* server has never stored (and, outside "cloud" storage mode, never will)
* gets clobbered back to its default the moment the fetch resolves. That
* made the toggle appear to do nothing at all.
*
* Locked is the default, so a fresh device never drags by accident.
*/
export function useArrangeLock(storageKey: string) {
const syncEvent = `${storageKey}:changed`;
const read = useCallback(() => {
try {
return localStorage.getItem(storageKey) === "false";
} catch {
return false;
}
}, [storageKey]);
const [unlocked, setUnlocked] = useState(read);
useEffect(() => {
const handler = () => setUnlocked(read());
window.addEventListener(syncEvent, handler);
window.addEventListener("storage", handler);
return () => {
window.removeEventListener(syncEvent, handler);
window.removeEventListener("storage", handler);
};
}, [syncEvent, read]);
const toggle = useCallback(() => {
const next = !read();
try {
localStorage.setItem(storageKey, next ? "false" : "true");
} catch {
/* ignore */
}
setUnlocked(next);
window.dispatchEvent(new Event(syncEvent));
return next;
}, [storageKey, syncEvent, read]);
return { arrangeLocked: !unlocked, toggleArrangeLock: toggle };
}