mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
+7






![dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)


Luke Gustafson
emreumar
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ZacharyZcR
Russell Poovey
russell
Subedi Bibek
Claude Fable 5
Alexander Elsner
SankeerthNara
Stephan Groth
DivByZero
devdanetra
Aleksandr Fominykh
sash
lhojun
Yunus Emre Umar
ddbdd5c437
* chore(deps): bump node from 24-slim to 26-slim in /docker in the docker-major-updates group (#1021) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore(deps): bump node in /docker in the docker-major-updates group Bumps the docker-major-updates group in /docker with 1 update: node. Updates `node` from 24-slim to 26-slim --- updated-dependencies: - dependency-name: node dependency-version: 26-slim dependency-type: direct:production dependency-group: docker-major-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: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-patch-updates group with 24 updates (#1023) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore(deps-dev): bump the dev-patch-updates group with 24 updates Bumps the dev-patch-updates group with 24 updates: | Package | From | To | | --- | --- | --- | | [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.1` | `2.5.2` | | [@codemirror/commands](https://github.com/codemirror/commands) | `6.10.3` | `6.10.4` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.1` | `6.43.5` | | [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.14` | `1.2.15` | | [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.17` | `1.1.18` | | [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.5` | `1.3.6` | | [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.17` | `1.1.18` | | [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.18` | `2.1.19` | | [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.10` | `2.1.11` | | [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.17` | `1.1.18` | | [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.10` | `1.1.11` | | [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.12` | `1.2.13` | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.1` | `2.3.2` | | [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.10` | `1.1.11` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.1` | `1.4.2` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.1` | `1.3.2` | | [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.15` | `1.1.16` | | [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.10` | `1.2.11` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.1` | `4.3.2` | | [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.2` | `6.0.3` | | [i18next](https://github.com/i18next/i18next) | `26.3.1` | `26.3.4` | | [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.0` | `1.6.1` | | [sharp](https://github.com/lovell/sharp) | `0.35.2` | `0.35.3` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.1` | `4.3.2` | Updates `@biomejs/biome` from 2.5.1 to 2.5.2 - [Release notes](https://github.com/biomejs/biome/releases) - [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md) - [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.5.2/packages/@biomejs/biome) Updates `@codemirror/commands` from 6.10.3 to 6.10.4 - [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/commands/commits) Updates `@codemirror/view` from 6.43.1 to 6.43.5 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `@radix-ui/react-accordion` from 1.2.14 to 1.2.15 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/accordion/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/accordion) Updates `@radix-ui/react-alert-dialog` from 1.1.17 to 1.1.18 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/alert-dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/alert-dialog) Updates `@radix-ui/react-checkbox` from 1.3.5 to 1.3.6 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox) Updates `@radix-ui/react-dialog` from 1.1.17 to 1.1.18 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog) Updates `@radix-ui/react-dropdown-menu` from 2.1.18 to 2.1.19 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu) Updates `@radix-ui/react-label` from 2.1.10 to 2.1.11 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label) Updates `@radix-ui/react-popover` from 1.1.17 to 1.1.18 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover) Updates `@radix-ui/react-progress` from 1.1.10 to 1.1.11 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress) Updates `@radix-ui/react-scroll-area` from 1.2.12 to 1.2.13 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area) Updates `@radix-ui/react-select` from 2.3.1 to 2.3.2 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-separator` from 1.1.10 to 1.1.11 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator) Updates `@radix-ui/react-slider` from 1.4.1 to 1.4.2 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider) Updates `@radix-ui/react-switch` from 1.3.1 to 1.3.2 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch) Updates `@radix-ui/react-tabs` from 1.1.15 to 1.1.16 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs) Updates `@radix-ui/react-tooltip` from 1.2.10 to 1.2.11 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip) Updates `@tailwindcss/vite` from 4.3.1 to 4.3.2 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.2/packages/@tailwindcss-vite) Updates `@vitejs/plugin-react` from 6.0.2 to 6.0.3 - [Release notes](https://github.com/vitejs/vite-plugin-react/releases) - [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.3/packages/plugin-react) Updates `i18next` from 26.3.1 to 26.3.4 - [Release notes](https://github.com/i18next/i18next/releases) - [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/i18next/compare/v26.3.1...v26.3.4) Updates `radix-ui` from 1.6.0 to 1.6.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui) Updates `sharp` from 0.35.2 to 0.35.3 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.35.2...v0.35.3) Updates `tailwindcss` from 4.3.1 to 4.3.2 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.2/packages/tailwindcss) --- updated-dependencies: - dependency-name: "@biomejs/biome" dependency-version: 2.5.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/commands" dependency-version: 6.10.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-accordion" dependency-version: 1.2.15 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-alert-dialog" dependency-version: 1.1.18 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-checkbox" dependency-version: 1.3.6 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-dialog" dependency-version: 1.1.18 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-dropdown-menu" dependency-version: 2.1.19 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-label" dependency-version: 2.1.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-popover" dependency-version: 1.1.18 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-progress" dependency-version: 1.1.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-scroll-area" dependency-version: 1.2.13 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-separator" dependency-version: 1.1.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-slider" dependency-version: 1.4.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-switch" dependency-version: 1.3.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-tabs" dependency-version: 1.1.16 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-tooltip" dependency-version: 1.2.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitejs/plugin-react" dependency-version: 6.0.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: i18next dependency-version: 26.3.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: radix-ui dependency-version: 1.6.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: sharp dependency-version: 0.35.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.3.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-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: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-patch-updates group with 2 updates (#1025) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore(deps): bump the prod-patch-updates group with 2 updates Bumps the prod-patch-updates group with 2 updates: [axios](https://github.com/axios/axios) and [nanoid](https://github.com/ai/nanoid). Updates `axios` from 1.18.0 to 1.18.1 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.18.0...v1.18.1) Updates `nanoid` from 5.1.15 to 5.1.16 - [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/5.1.15...5.1.16) --- updated-dependencies: - dependency-name: axios dependency-version: 1.18.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: nanoid dependency-version: 5.1.16 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-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: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-minor-updates group with 3 updates (#1026) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore(deps): bump the prod-minor-updates group with 3 updates Bumps the prod-minor-updates group with 3 updates: [js-yaml](https://github.com/nodeca/js-yaml), [motion](https://github.com/motiondivision/motion) and [undici](https://github.com/nodejs/undici). Updates `js-yaml` from 5.0.0 to 5.2.1 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.0.0...5.2.1) Updates `motion` from 12.40.0 to 12.42.2 - [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md) - [Commits](https://github.com/motiondivision/motion/compare/v12.40.0...v12.42.2) Updates `undici` from 8.5.0 to 8.7.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.5.0...v8.7.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 5.2.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: motion dependency-version: 12.42.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: undici dependency-version: 8.7.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: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump electron from 42.4.1 to 43.0.0 in the major-updates group (#1027) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore(deps-dev): bump electron in the major-updates group Bumps the major-updates group with 1 update: [electron](https://github.com/electron/electron). Updates `electron` from 42.4.1 to 43.0.0 - [Release notes](https://github.com/electron/electron/releases) - [Commits](https://github.com/electron/electron/compare/v42.4.1...v43.0.0) --- updated-dependencies: - dependency-name: electron dependency-version: 43.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-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: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Fix MC syntax highlighting artifacts (#996) * Filter dashboard status hosts (#997) * Persist dashboard service link changes (#999) * Fix snippet text overflow (#1000) * Persist remote desktop credential auth (#1001) * Guard language switching failures (#1002) * Resolve tunnel source credentials (#1003) * Support Vault auth for monitors (#1004) * Fix Windows file delete command (#1005) * Fix release artifact checkout ref (#1006) * Fix command palette escape in fullscreen (#1008) * Fix alerts and audit log data normalization (#1010) * Fix macOS VNC protocol negotiation (#1012) * Fix port knocking before SSH connect (#1013) * Allow Escape to close link confirmation (#1014) * Prevent Electron modifier wheel zoom (#1016) * Fix credential auth optional password (#1009) * Retry transient terminal DNS lookups (#1011) * Retry transient terminal DNS lookups * Apply DNS retry to SSH entry points * Fix OIDC redirect forwarded port handling (#1007) * Preserve recent open tabs on startup (#1015) * Fix fish prompt OSC highlighting (#998) * Fix terminal font selection (#1018) * fix: font legibility (#1019) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * fix: font legibility Text was entirely unreadable in places for me. Especially with themes like Catppuccin. The muted-foreground text and the tags too similiar to the background. --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: russell <git@0896c69e.com> * fix(file-manager): chunked uploads fail with 'Expected multipart/form-data request' (#1020) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * fix(file-manager): use postForm for chunked uploads so multipart content-type is sent The fileManagerApi axios instance defaults to Content-Type: application/json. Axios 1.x's default transformRequest converts a FormData body to JSON whenever the request content type is application/json, so every chunk POSTed to /ssh/uploadFileChunk arrived as a JSON body like {"chunk":{}} and the backend rejected it with 400 'Expected multipart/form-data request'. This breaks all uploads of files larger than the 1.5 GiB chunking threshold. The non-chunked path already uses postForm for /ssh/uploadFileStream; use it for the chunk path too so axios keeps the FormData intact and the browser sets the multipart boundary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> * feat: implement OIDC back-channel logout support with session management (#1028) * feat: implement OIDC back-channel logout support with session management * Fix OIDC back-channel logout handling * Require logout token replay identifiers --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * Add API key host enrollment endpoint (#1029) * Fix tmux detection for non-POSIX shells (#1030) * Fix OPKSSH js-yaml ESM import (#1031) * Fix Android Vietnamese IME input (#1032) * Fix Firefox RDP clipboard paste (#1033) * Fix Proxmox discovery over HTTPS (#1041) * Fix external editor actions in file preview (#1042) * Allow pinned hosts with name sorting (#1043) * Fix Firefox desktop OIDC callback (#1044) * feat(session): add recording and replay (#1049) * Fix status checks through jump hosts (#1045) * Add terminal font size shortcuts (#1047) * feat: add Open File Manager to tab right-click menu (#1051) Co-authored-by: SankeerthNara <sankeerthnara@gmail.com> * perf: frontend request cache, poll pause, and code-split shell (#1052) Host/status caching, shell code-split, SSH pool waits, host-metrics concurrency, background-tab idle, per-host status subscriptions, homepage poll quieting, and virtualized host sidebar + file manager lists. * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * feat: save quick connect sessions as hosts (#1055) * fix: restore sudo password autofill settings (#1056) * fix: preserve file editor position on save (#1057) * fix: sync cloud preference storage mode (#1058) * fix: render RDP sessions at native pixel density (#1059) * fix: restore database import in embedded desktop mode (#1060) * Update Auto-complete.tsx (#1061) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore: donation bar reporting wrong result * feat: add Open File Manager to tab right-click menu (#1046) * Revert "feat: add Open File Manager to tab right-click menu (#1046)" (#1050) This reverts commit0712fdd731. * Remove donation badge from README Removed donation badge from README. * Delete .github/workflows/donation-goal.yml * Update Auto-complete.tsx --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * feat(auth): opt-in OIDC DEK unlock for API-key requests (ALLOW_APIKEY_DATA_UNLOCK) (#1064) * chore: fix release workflow to merge docs branch * fix: svg donation generator push fail * fix: svg donation generator push fail * Update termix.rb * fix: svg donation generator push fail * chore: move donation badge to badges branch to avoid ruleset conflicts * chore: remove unneeded token from donation badge workflow * chore: debug donation badge commit step * fix: escape < character in donation SVG * fix: point donation badge to badges branch * chore: remove unused donation badge svg from main * Add Rack Genius logo to README Added Rack Genius logo to the README. * chore: improve donation goal svg generator to include stablecoins * chore: donation goal generator syntax error * chore: donation goal generator incorrect docs url usage * chore: donation bar reporting wrong result * feat: add Open File Manager to tab right-click menu (#1046) * Revert "feat: add Open File Manager to tab right-click menu (#1046)" (#1050) This reverts commit0712fdd731. * Remove donation badge from README Removed donation badge from README. * Delete .github/workflows/donation-goal.yml * feat(auth): opt-in OIDC DEK unlock for API-key requests API keys authenticate but cannot touch the encrypted credential/host store ('User data not unlocked') unless the user has a live interactive session, making them unusable for headless automation. For OIDC users the DEK is server-derivable (deriveOIDCSystemKey), so handleApiKeyAuth can unlock it without a password. Gated behind ALLOW_APIKEY_DATA_UNLOCK (default off) because enabling it widens the blast radius of a leaked API key. OIDC-only; password users are untouched. Refs #1063 --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Sankeerth Nara <sankeerthnara@gmail.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * chore: package lock sync * Add Proxmox guest auto sync (#1053) * draft: database layer refactor (#1054) * feat(sshid) - sshid.io equivalent for termix (#919) * feat(ssh-id): database schema, migrations and field encryption Adds ssh_identities, ssh_identity_keys and ssh_identity_ca tables (public keys stored plaintext for the unauthenticated resolver; CA private key registered for per-user field encryption), with UNIQUE(user_id), an index on ssh_identity_keys(identity_id), and idempotent CREATE TABLE migrations. * feat(ssh-id): backend API — resolver, key management, CA and certificates Mounts /sshid (nginx route added). Public text/plain authorized_keys resolver (+ exact /:algo filter, HTML viewer) and CA public-key endpoint; no-store + noindex headers on every resolver response including early 404s. Authenticated management: claim/rename/delete handle, add/import/generate/enable/delete keys, and a per-user CA (create/rotate/delete) with pure-Node OpenSSH certificate issuance. Audit logging on all mutations; UNIQUE races map to a precise 409. Unit tests for key parsing and certificate signing (ssh-keygen-validated). * feat(ssh-id): frontend panel, API client and i18n SSH ID panel wired into the app rail and AppShell: claim handle, resolver URL + curl one-liner, key list, generate, paste/import, CA enable/rotate/remove with server trust command, and per-key certificate issuance. API client re-exported through main-axios.ts; all strings i18n'd. * style(ssh-id): align panel and resolver page with Termix theme - Rebuild the SSH ID sidebar panel with the theme's square components (SectionCard / SettingRow / FakeSwitch) instead of rounded ad-hoc cards; use accent-brand and destructive tokens rather than raw red/green. - Fix panel scrolling: move overflow to a block scroll container so the cards keep their natural height instead of being clipped. - Restyle the public resolver HTML page (/sshid/u/:handle) to the Termix dark theme: square corners, #18181b/#303032 palette, #f59145 accent, uppercase section labels. - Tidy copy: 'Save To Credentials' label, drop the redundant generate intro, and correct the generate tooltip (the key is stored when saving to vault). * feat: rename to Termix ID, improve UI, backend inconsistencies, and general bug fixes --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * ci(deps): bump actions/checkout from 6 to 7 in the github-actions group (#922) Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 6 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps-dev): bump the dev-patch-updates group with 11 updates (#923) Bumps the dev-patch-updates group with 11 updates: | Package | From | To | | --- | --- | --- | | [@codemirror/search](https://github.com/codemirror/search) | `6.7.0` | `6.7.1` | | [@codemirror/view](https://github.com/codemirror/view) | `6.43.0` | `6.43.1` | | [@tailwindcss/vite](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-vite) | `4.3.0` | `4.3.1` | | [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.8` | `4.1.9` | | [@vitest/ui](https://github.com/vitest-dev/vitest/tree/HEAD/packages/ui) | `4.1.8` | `4.1.9` | | [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.2` | `0.5.3` | | [lint-staged](https://github.com/lint-staged/lint-staged) | `17.0.7` | `17.0.8` | | [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` | | [sharp](https://github.com/lovell/sharp) | `0.35.1` | `0.35.2` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` | | [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.8` | `4.1.9` | Updates `@codemirror/search` from 6.7.0 to 6.7.1 - [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/search/commits) Updates `@codemirror/view` from 6.43.0 to 6.43.1 - [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md) - [Commits](https://github.com/codemirror/view/commits) Updates `@tailwindcss/vite` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-vite) Updates `@vitest/coverage-v8` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/coverage-v8) Updates `@vitest/ui` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/ui) Updates `eslint-plugin-react-refresh` from 0.5.2 to 0.5.3 - [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases) - [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md) - [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.2...v0.5.3) Updates `lint-staged` from 17.0.7 to 17.0.8 - [Release notes](https://github.com/lint-staged/lint-staged/releases) - [Changelog](https://github.com/lint-staged/lint-staged/blob/main/CHANGELOG.md) - [Commits](https://github.com/lint-staged/lint-staged/compare/v17.0.7...v17.0.8) Updates `prettier` from 3.8.3 to 3.8.4 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4) Updates `sharp` from 0.35.1 to 0.35.2 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.35.1...v0.35.2) Updates `tailwindcss` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/tailwindcss) Updates `vitest` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest) --- updated-dependencies: - dependency-name: "@codemirror/search" dependency-version: 6.7.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@codemirror/view" dependency-version: 6.43.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@tailwindcss/vite" dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/coverage-v8" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitest/ui" dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: eslint-plugin-react-refresh dependency-version: 0.5.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: lint-staged dependency-version: 17.0.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: prettier dependency-version: 3.8.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: sharp dependency-version: 0.35.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: tailwindcss dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: vitest dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump nanoid in the prod-patch-updates group (#925) Bumps the prod-patch-updates group with 1 update: [nanoid](https://github.com/ai/nanoid). Updates `nanoid` from 5.1.11 to 5.1.15 - [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/5.1.11...5.1.15) --- updated-dependencies: - dependency-name: nanoid dependency-version: 5.1.15 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 the major-updates group with 5 updates (#926) Bumps the major-updates group with 5 updates: | Package | From | To | | --- | --- | --- | | [js-yaml](https://github.com/nodeca/js-yaml) | `4.2.0` | `5.0.0` | | [@eslint/js](https://github.com/eslint/eslint/tree/HEAD/packages/js) | `9.39.4` | `10.0.1` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.2` | `26.0.0` | | [concurrently](https://github.com/open-cli-tools/concurrently) | `9.2.1` | `10.0.3` | | [eslint](https://github.com/eslint/eslint) | `9.39.4` | `10.5.0` | Updates `js-yaml` from 4.2.0 to 5.0.0 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.2.0...5.0.0) Updates `@eslint/js` from 9.39.4 to 10.0.1 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/commits/v10.0.1/packages/js) Updates `@types/node` from 25.9.2 to 26.0.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `concurrently` from 9.2.1 to 10.0.3 - [Release notes](https://github.com/open-cli-tools/concurrently/releases) - [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v10.0.3) Updates `eslint` from 9.39.4 to 10.5.0 - [Release notes](https://github.com/eslint/eslint/releases) - [Commits](https://github.com/eslint/eslint/compare/v9.39.4...v10.5.0) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 5.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@eslint/js" dependency-version: 10.0.1 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@types/node" dependency-version: 26.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: concurrently dependency-version: 10.0.3 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: eslint dependency-version: 10.5.0 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> * feat(ssh): add HashiCorp Vault SSH signer authentication * fix: small fixes to vault feature to align with Termix codebase * chore: add view docs links for vault/termix id * fix: file upload fails with 400 and missing schema migrations on upgrade (#929) Two bugs introduced in v2.4.1: 1. uploadFileStream uses fileManagerApi.post() which triggers axios's transformRequest to JSON-serialize the FormData because the instance default Content-Type is application/json. Change to postForm() which sets Content-Type: multipart/form-data so the browser XHR sends the correct multipart body with boundary. 2. Two schema items added to schema.ts were not included in migrateSchema() in db/index.ts, causing 500 errors on existing installations upgrading from v2.4.0: - user_preferences.status_color_scheme (no such column) - dashboard_service_links table (no such table) Fixes #928 Co-authored-by: sash <sash@fominykh.io> * fix: support PuTTY PPK ssh keys (#930) * fix: chunk large file manager uploads (#932) * fix: route dashboard hosts by protocol (#934) * fix: resolve tunnel endpoints reliably (#935) * Fix Electron OIDC browser auth failures (#936) * Allow RDP connections without stored credentials (#937) * Sync role credential shares for OIDC users (#938) * Fix terminal link dialog layering (#940) * Confirm large files before opening editor (#942) * Confirm closing active host connections (#943) * Preserve file path case in file manager UI (#941) * fix: preserve unicode guacamole tokens (#933) * Persist VNC authentication settings (#944) * Fix Guacamole websocket base path (#946) * Promote file manager terminals to tabs (#939) * Guard Guacamole disconnect during startup (#945) * chore: increment ver * feat: bitwarden ssh agent integration * feat: serial connections support * fix: various small bug fixes * feat: open all sessions in a folder and terminal custom theme color support * feat: cross host file manager clipboard and several small bug fixes * feat: tailscale/wireguard support and added a new status state for when backend is checking status * feat: grafana like server stats history, new alert system, ntfy/webhook support * feat: new grid and widget based homepage function * feat: new donate button in dashboard * fix: alert ui incorrectly using termix css and fixed issue with alert system not loading * chore: start database layer refactor * docs: plan database layer refactor * docs: audit database layer refactor phase zero * chore: add database runtime adapter skeleton * chore: add settings repository skeleton * chore: add user session repository skeleton * chore: add host credential repository skeleton * chore: add field encryption boundary * chore: migrate settings route slice * chore: migrate user settings routes * chore: migrate host metrics settings routes * chore: migrate acme settings route * chore: migrate terminal settings route * chore: migrate tailscale settings read * chore: migrate guacamole settings reads * chore: migrate session timeout settings reads * chore: migrate auth route settings reads * chore: migrate host metrics settings reads * chore: migrate startup settings reads * chore: migrate user settings cleanup * chore: migrate password reset settings * chore: migrate oidc legacy settings read * chore: migrate user route settings slice * chore: migrate oidc state settings * chore: migrate user login settings reads * chore: migrate user crypto settings * chore: consolidate startup settings defaults * chore: consolidate database settings import export * chore: migrate core session auth paths * chore: migrate remaining session auth paths * chore: migrate admin user routes * chore: migrate user route admin checks * chore: migrate user lifecycle routes * chore: migrate auth user lookups * chore: migrate oidc user routes * chore: migrate api key repository paths * docs: add database gray rollout guide * chore: migrate trusted device paths * chore: migrate user session route user lookups * chore: add database repository rollout guard * chore: expose repository rollout status * chore: warn on repository rollout misconfiguration * chore: migrate remaining user lookup helpers * chore: migrate ssh user lookups * chore: migrate user settings admin lookups * chore: migrate acme ssl user lookups * chore: migrate audit log admin checks * chore: migrate oidc account user updates * chore: migrate password reset user updates * chore: migrate user deletion core records * chore: migrate snippet audit user lookups * chore: migrate ldap user sync paths * chore: migrate totp user updates * chore: migrate rbac user checks * chore: migrate rbac role paths * chore: migrate permission role lookups * chore: migrate rbac access list reads * chore: migrate shared rbac reads * chore: migrate rbac access writes * chore: migrate permission host access * chore: migrate role host access lookup * chore: migrate snippet access lookup * chore: migrate shared credential access lookups * chore: migrate host access cleanup writes * chore: migrate host list access checks * chore: migrate host access cleanup routes * chore: migrate shared credential role lookups * chore: migrate user role cleanup * chore: migrate admin role sync * chore: migrate ldap role sync * chore: migrate user role assignment * chore: migrate sso provider access * chore: migrate audit log access * chore: migrate user preference access * chore: migrate open tab access * chore: migrate dismissed alert access * chore: migrate homepage layout access * chore: migrate network topology access * chore: migrate dashboard service link access * chore: migrate command history access * chore: migrate recent activity cleanup * chore: migrate ssh credential usage access * chore: migrate transfer recent access * chore: migrate file manager bookmark access * chore: migrate c2s tunnel preset access * chore: migrate homepage item access * chore: migrate session recording access * chore: migrate tmux session tag access * chore: migrate opkssh token access * chore: migrate vault token access * chore: migrate vault profile access * chore: migrate host metrics preference access * chore: migrate host health access * chore: migrate host metrics history access * chore: migrate alert persistence access * chore: route alert host lookup through repository * chore: migrate user data export reads * chore: route host metrics stats sync through repository * chore: migrate host folder persistence * chore: migrate host resolution reads * chore: route jump host resolution reads * chore: route docker console jump host reads * chore: route docker ssh resolution reads * chore: route proxmox discovery resolution reads * chore: route file manager activity host reads * chore: route host metrics resolution reads * chore: route ssh auth credential reads * chore: route tunnel endpoint credential reads * chore: route credential deployment resolution reads * chore: route command history host flag reads * chore: route snippet execution resolution reads * chore: route terminal host resolution reads * chore: route vault oidc host resolution reads * chore: route wake on lan host reads * chore: route internal host list reads * chore: route host key verification persistence * chore: route credential read paths * chore: route credential host usage reads * chore: route credential folder rename * chore: route host owner access checks * chore: route shared credential source reads * chore: route user host credential cleanup * chore: route credential delete reads * chore: route credential update reads * chore: route host credential reads * chore: route host read paths * chore: route host projection reads * chore: route host list reads * chore: route snippet read paths * chore: route snippet folder writes * chore: route snippet crud paths * chore: route snippet bulk import * chore: route rbac ownership reads * chore: route user count reads * chore: route cleanup snippets folders * chore: route shared credential persistence * chore: route dashboard activity * chore: route guacamole host reads * chore: route host bulk lookups * chore: remove unlock-only simple db ops * chore: route host autostart persistence * chore: route ldap provisioning through users * chore: route credential encrypted writes * chore: route host encrypted writes * chore: route bulk host encrypted writes * chore: route termix id credentials * chore: route termix id ca persistence * chore: route termix identity persistence * chore: route credential system migration * chore: isolate user encryption migration storage * chore: remove legacy simple db ops * chore: isolate legacy sqlite migration copy * chore: route database settings import export * chore: route database host credential export * chore: route database host credential import * chore: route database file-manager import export * chore: route database alert usage import export * chore: route database user checks * chore: isolate auth lazy migration storage * chore: route explicit database saves * chore: initialize database save boundary * chore: route migration snapshot saves * chore: isolate sqlite import constraints * chore: route import sqlite boundary * chore: route user encryption migration store * chore: centralize current repository runtime * chore: route more current repositories * chore: route activity repository runtimes * chore: route token repository runtimes * chore: route health repository runtimes * chore: route identity repository runtimes * chore: route rbac repository runtime * chore: centralize current sqlite runtime access * chore: route user deletion key cleanup * chore: route user deletion vault cleanup * chore: route user deletion homepage cleanup * chore: route user deletion health cleanup * chore: route user deletion alert cleanup * chore: route user deletion identity cleanup * chore: add database layer preupgrade backup * Fix database repository type errors * fix: complete post-merge compile fixes for database refactor Restore missing DatabaseSaveTrigger/getDb imports, session log format fallback, OIDC provider resolution, guacamole recording insert, and passwordFallbackOnly typing after merging current dev. --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: DivByZero <mr.oplus@yahoo.fr> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com> Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com> Co-authored-by: sash <sash@fominykh.io> * refactor(db): collapse repository rollout scaffolding into single factory Repositories are now the only data path. Replaces the 41 current-*-repository wrapper files, the DATABASE_LAYER_REPOSITORY_ROLLOUT flag/alias map and the unused database/runtime adapter with repositories/factory.ts, a plain DatabaseContext type and an in-memory TestSqliteDatabase test harness. * refactor(db): route remaining raw DB access through repositories proxmox, session-log, oidc-utils, webauthn and guacamole recording now use repositories (new WebauthnCredentialRepository; SsoProviderRepository listEnabled; HostRepository findDecryptedByIdAs/listProxmoxEnabled). Remaining raw access: db boot code, simple-db-ops and docker.ts, which are removed/restructured in later phases. * feat(crypto): add UserKeyManager with system-wrapped per-user DEKs New utils/user-keys.ts: one random 32-byte DEK per user, wrapped AES-256-GCM under an HKDF key derived from the system ENCRYPTION_KEY (per-user info string + AAD binding, versioned v3 wrap format stored in settings). Synchronous unwrap-on-demand with a 15-minute cache so the existing DataCrypto facade keeps its sync call sites. Not wired up yet. * feat(crypto): boot-time DEK migration to system-wrapped v3 format utils/crypto-migration/dek-migration.ts carries the legacy unwrap paths (PBKDF2 password KEK, OIDC/WebAuthn system keys, hardcoded-default fallback) and migrates every server-unwrappable DEK to the v3 wrap at startup. Password-wrapped DEKs migrate at next login or from a live session via adoptRecoveredDEK. Legacy rows are kept for now; cleanup flips on once the new path is authoritative. * refactor(crypto): make system-wrapped DEKs the authoritative key path DataCrypto and AuthManager now read keys through UserKeyManager: DEKs are always unwrappable server-side, so the in-memory unlock session, DEK-in-JWT wrapping, session-expiry data locks and ALLOW_APIKEY_DATA_UNLOCK are gone. utils/user-crypto.ts is deleted; boot migration now cleans legacy wraps. A one-release shim adopts DEKs from legacy dataKeyWrap tokens so active password users migrate without re-login. Password login migrates legacy password-wrapped DEKs via migratePasswordUserAtLogin. * refactor(crypto): remove pending share queue and credential sharing key With server-unwrappable DEKs both sides of a share are always available, so the needsReEncryption queue, CREDENTIAL_SHARING_KEY and the system_* shadow columns on ssh_credentials are gone. A one-time boot cleanup re-creates legacy pending share copies where possible (dropping unresolvable ones with a warning) and drops the legacy columns. * feat(auth): non-destructive password resets and admin reset endpoint Password resets no longer destroy user data: the DEK is system-wrapped, so forgot-password and admin resets are just a hash update plus session revoke. The wipe branch survives only for accounts that never logged in since the encryption upgrade and now requires explicit confirmDataWipe (surfaced as a 409 DATA_WIPE_REQUIRED; the reset UI asks for confirmation). Adds POST /users/admin/reset-password and removes the dead re-encryption paths. * refactor(ssh): consolidate four jump-host chain copies into one module terminal, host-metrics and docker now use ssh/jump-host-chain.ts (already shared by file-manager, tmux-monitor and docker-console); docker's inline copy also drops its raw SimpleDBOps host/credential lookups in favor of repositories. * refactor(ssh): single shared createConnectionLog helper file-manager-log.ts becomes ssh/connection-log.ts; the copies in docker.ts and host-metrics-helpers.ts are gone. * refactor(ssh): split docker module into layered directory ssh/docker/{index,routes,session-manager,container-routes,console}.ts: server boot and wiring in index, HTTP handlers in routes, SSH session registry and command execution in session-manager. Code motion only; port 30007/30009 and endpoints unchanged. Swagger now scans ssh subdirectories. * refactor(ssh): split tunnel module into layered directory ssh/tunnel/{index,routes,manager}.ts: server boot in index, HTTP handlers in routes, tunnel state and engine (connect/retry/autostart) in manager. Code motion only; port 30003 and endpoints unchanged. * refactor(backend): reorganize top-level layout - ssh/ renamed to hosts/ (it covers SSH, RDP, VNC, Telnet, Docker, metrics) - serial/serial.ts and guacamole/ moved inside hosts/ - dashboard.ts and homepage.ts moved to services/ - swagger.ts moved to utils/ with adjusted scan globs Import paths and the generate:openapi script updated; ports and endpoints unchanged. * refactor(tests): move backend tests into src/backend/tests mirror tree Backend *.test.ts files (and the test-support harness) no longer sit next to source files; they live under src/backend/tests/ mirroring the source layout. Imports rewritten accordingly; CLAUDE.md convention updated. * refactor(hosts): group host modules into per-feature directories file-manager/, metrics/ (incl. widgets, managers, alert-engine), terminal/, tmux/ and tunnel/ each own their files; docker/ gains container-runtime. Genuinely shared helpers (jump-host chain, host resolver, connection pool, opkssh, vault, serial) stay at hosts/ root. Pure file moves with import path updates; mirrored test paths follow. * refactor(backend): final cleanup pass - re-register WebAuthn passkey routes (registration was dropped in the #1054 merge, breaking passkey login) and document all six endpoints - delete utils/simple-db-ops.ts (last caller migrated to DataCrypto) - starter: use the typed serverReady export, collapse the four-way version lookup to env then package.json candidates - add OpenAPI JSDoc to c2s-tunnel-presets endpoints - strip block-divider comment banners * feat: remove legacy "data_unlocked" field * feat: refactor rbac/sharing to support new permissions and auth types * feat: refactor rbac/sharing to support new permissions and auth types * feat: add "id" to user profile hide list * chore: root cleanup * feat: add more donation references and a 30-day donation reminder * chore: update readme * feat: automate beta tests * feat: add links to milestones * fix: hoist github/google SSO defaults to module scope (#1065) * fix(ssh-tools): allow clipboard paste in key recording field (#1066) The broadcast key-recording input was marked readOnly, which makes browsers block paste entirely (no context-menu Paste, Ctrl+V does nothing). handleKeyDown also called preventDefault() unconditionally, swallowing the Ctrl+V shortcut before a paste event could even fire. Let Ctrl/Cmd+V pass through in handleKeyDown, drop readOnly, and add an onPaste handler that reads the clipboard text and broadcasts it to the selected terminals like any other captured keystroke. Signed-off-by: emreumar <emreumar@users.noreply.github.com> Co-authored-by: emreumar <emreumar@users.noreply.github.com> * chore: write release notes * chore: update release notes * chore: update readmes * chore: add crypto only reminder in en.json * fix: macOS and cask errors on release workflow * chore: sync Crowdin translations for 2.5.1 --------- Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: emreumar <emreumar@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: Russell Poovey <09.our_seekers@icloud.com> Co-authored-by: russell <git@0896c69e.com> Co-authored-by: Subedi Bibek <77529535+questbibek@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Alexander Elsner <101340634+Bensonheimer992@users.noreply.github.com> Co-authored-by: SankeerthNara <sankeerthnara@gmail.com> Co-authored-by: Stephan Groth <96803994+Kalvalax@users.noreply.github.com> Co-authored-by: DivByZero <mr.oplus@yahoo.fr> Co-authored-by: devdanetra <46488477+devdanetra@users.noreply.github.com> Co-authored-by: Aleksandr Fominykh <neoformalex@users.noreply.github.com> Co-authored-by: sash <sash@fominykh.io> Co-authored-by: lhojun <ldgs3324@gmail.com> Co-authored-by: Yunus Emre Umar <77045015+emre155@users.noreply.github.com> Co-authored-by: emreumar <emreumar@users.noreply.github.com>
3132 lines
91 KiB
TypeScript
3132 lines
91 KiB
TypeScript
/* eslint-disable react-hooks/exhaustive-deps */
|
|
import React, {
|
|
useState,
|
|
useEffect,
|
|
useRef,
|
|
useCallback,
|
|
useMemo,
|
|
} from "react";
|
|
import { cn } from "@/lib/utils.ts";
|
|
import { FileManagerGrid } from "./FileManagerGrid.tsx";
|
|
import { FileManagerSidebar } from "./FileManagerSidebar.tsx";
|
|
import { FileManagerContextMenu } from "./FileManagerContextMenu.tsx";
|
|
import { useFileSelection } from "./hooks/useFileSelection.ts";
|
|
import { useDragAndDrop } from "./hooks/useDragAndDrop.ts";
|
|
import {
|
|
WindowManager,
|
|
useWindowManager,
|
|
} from "./components/WindowManager.tsx";
|
|
import { FileWindow } from "./components/FileWindow.tsx";
|
|
import { DiffWindow } from "./components/DiffWindow.tsx";
|
|
import { useDragToDesktop } from "@/features/file-manager/hooks/useDragToDesktop";
|
|
import { useDragToSystemDesktop } from "@/features/file-manager/hooks/useDragToSystemDesktop";
|
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
|
import { toast } from "sonner";
|
|
import { useTranslation } from "react-i18next";
|
|
import { FileManagerDialogs } from "./FileManagerDialogs.tsx";
|
|
import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx";
|
|
import { FileManagerToolbar } from "./FileManagerToolbar.tsx";
|
|
import { TransferToHostDialog } from "./components/TransferToHostDialog.tsx";
|
|
import { TerminalWindow } from "./components/TerminalWindow.tsx";
|
|
import type { SSHHost, FileItem } from "@/types/index";
|
|
import {
|
|
ConnectionLogProvider,
|
|
useConnectionLog,
|
|
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
|
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
|
|
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
|
import { copyToClipboard } from "@/lib/clipboard.ts";
|
|
import {
|
|
listSSHFiles,
|
|
resolveSSHPath,
|
|
uploadSSHFile,
|
|
createSSHFile,
|
|
createSSHFolder,
|
|
deleteSSHItem,
|
|
copySSHItem,
|
|
renameSSHItem,
|
|
moveSSHItem,
|
|
connectSSH,
|
|
verifySSHTOTP,
|
|
verifySSHWarpgate,
|
|
getSSHStatus,
|
|
keepSSHAlive,
|
|
identifySSHSymlink,
|
|
addRecentFile,
|
|
addPinnedFile,
|
|
removePinnedFile,
|
|
removeRecentFile,
|
|
addFolderShortcut,
|
|
getPinnedFiles,
|
|
logActivity,
|
|
changeSSHPermissions,
|
|
extractSSHArchive,
|
|
compressSSHFiles,
|
|
setSudoPassword,
|
|
getServerMetricsById,
|
|
transferToHost,
|
|
addTransferRecent,
|
|
type TransferMethodPreference,
|
|
} from "@/main-axios.ts";
|
|
import { beginTransferProgressMonitoring } from "./transferProgressMonitor.tsx";
|
|
import { createFormatTransferMetrics } from "./transferMetricsFormat.ts";
|
|
import type { SidebarItem } from "./FileManagerSidebar.tsx";
|
|
import type {
|
|
CreateIntent,
|
|
FileManagerProps,
|
|
PendingSudoOperation,
|
|
SSHConnectionError,
|
|
} from "./file-manager-types.ts";
|
|
import { formatFileSize } from "./file-manager-utils.ts";
|
|
|
|
const LARGE_FILE_WARNING_SIZE = 50 * 1024 * 1024;
|
|
|
|
function FileManagerContent({
|
|
initialHost,
|
|
initialFilePath,
|
|
initialPath,
|
|
onClose,
|
|
onOpenTerminalTab,
|
|
isVisible = true,
|
|
}: FileManagerProps) {
|
|
const { openWindow } = useWindowManager();
|
|
const { t } = useTranslation();
|
|
const formatTransferMetrics = useMemo(
|
|
() => createFormatTransferMetrics(t),
|
|
[t],
|
|
);
|
|
const { confirmWithToast } = useConfirmation();
|
|
const {
|
|
addLog,
|
|
clearLogs,
|
|
isExpanded: isConnectionLogExpanded,
|
|
} = useConnectionLog();
|
|
|
|
const [currentHost] = useState<SSHHost | null>(initialHost || null);
|
|
const [currentPath, setCurrentPath] = useState(
|
|
initialPath || initialHost?.defaultPath || "/",
|
|
);
|
|
const [navHistory, setNavHistory] = useState<string[]>([
|
|
initialPath || initialHost?.defaultPath || "/",
|
|
]);
|
|
const [navIndex, setNavIndex] = useState(0);
|
|
const [files, setFiles] = useState<FileItem[]>([]);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
|
const [isReconnecting, setIsReconnecting] = useState<boolean>(false);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0);
|
|
const [viewMode, setViewMode] = useState<"grid" | "list">(() => {
|
|
const saved = localStorage.getItem("fileManagerViewMode");
|
|
return saved === "grid" || saved === "list" ? saved : "grid";
|
|
});
|
|
const [sortBy, setSortBy] = useState<"name" | "modified" | "size">(() => {
|
|
const saved = localStorage.getItem("fileManagerSortBy");
|
|
return saved === "name" || saved === "modified" || saved === "size"
|
|
? saved
|
|
: "name";
|
|
});
|
|
const [sortOrder, setSortOrder] = useState<"asc" | "desc">(() => {
|
|
const saved = localStorage.getItem("fileManagerSortOrder");
|
|
return saved === "asc" || saved === "desc" ? saved : "asc";
|
|
});
|
|
const [totpRequired, setTotpRequired] = useState(false);
|
|
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
|
|
const [totpPrompt, setTotpPrompt] = useState<string>("");
|
|
const [warpgateRequired, setWarpgateRequired] = useState(false);
|
|
const [warpgateSessionId, setWarpgateSessionId] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [warpgateUrl, setWarpgateUrl] = useState<string>("");
|
|
const [warpgateSecurityKey, setWarpgateSecurityKey] = useState<string>("");
|
|
const [showAuthDialog, setShowAuthDialog] = useState(false);
|
|
const [authDialogReason, setAuthDialogReason] = useState<
|
|
"no_keyboard" | "auth_failed" | "timeout"
|
|
>("no_keyboard");
|
|
const [showPassphraseDialog, setShowPassphraseDialog] = useState(false);
|
|
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
|
|
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
|
|
const [hasConnectionError, setHasConnectionError] = useState<boolean>(false);
|
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
|
const [diskInfo, setDiskInfo] = useState<{
|
|
usedHuman: string;
|
|
totalHuman: string;
|
|
percent: number;
|
|
} | null>(null);
|
|
|
|
const [contextMenu, setContextMenu] = useState<{
|
|
x: number;
|
|
y: number;
|
|
isVisible: boolean;
|
|
files: FileItem[];
|
|
}>({
|
|
x: 0,
|
|
y: 0,
|
|
isVisible: false,
|
|
files: [],
|
|
});
|
|
|
|
const [clipboard, setClipboard] = useState<{
|
|
files: FileItem[];
|
|
operation: "copy" | "cut";
|
|
sourceHostId: number | null;
|
|
sourceSessionId: string | null;
|
|
} | null>(null);
|
|
|
|
interface UndoAction {
|
|
type: "copy" | "cut" | "delete";
|
|
description: string;
|
|
data: {
|
|
operation: "copy" | "cut";
|
|
copiedFiles?: {
|
|
originalPath: string;
|
|
targetPath: string;
|
|
targetName: string;
|
|
}[];
|
|
deletedFiles?: { path: string; name: string }[];
|
|
targetDirectory?: string;
|
|
};
|
|
timestamp: number;
|
|
}
|
|
|
|
const [undoHistory, setUndoHistory] = useState<UndoAction[]>([]);
|
|
|
|
const [createIntent, setCreateIntent] = useState<CreateIntent | null>(null);
|
|
const [editingFile, setEditingFile] = useState<FileItem | null>(null);
|
|
const [permissionsDialogFile, setPermissionsDialogFile] =
|
|
useState<FileItem | null>(null);
|
|
const [compressDialogFiles, setCompressDialogFiles] = useState<FileItem[]>(
|
|
[],
|
|
);
|
|
const [transferDialogOpen, setTransferDialogOpen] = useState(false);
|
|
const [transferFiles, setTransferFiles] = useState<FileItem[]>([]);
|
|
const [transferMove, setTransferMove] = useState(false);
|
|
|
|
const [sudoDialogOpen, setSudoDialogOpen] = useState(false);
|
|
const [pendingSudoOperation, setPendingSudoOperation] =
|
|
useState<PendingSudoOperation | null>(null);
|
|
|
|
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
|
|
|
|
const { dragHandlers } = useDragAndDrop({
|
|
onFilesDropped: handleFilesDropped,
|
|
onItemsDropped: handleItemsDropped,
|
|
onError: (error) => toast.error(error),
|
|
maxFileSize: 5120,
|
|
});
|
|
|
|
const dragToDesktop = useDragToDesktop({
|
|
sshSessionId: sshSessionId || "",
|
|
sshHost: currentHost!,
|
|
});
|
|
|
|
const systemDrag = useDragToSystemDesktop({
|
|
sshSessionId: sshSessionId || "",
|
|
sshHost: currentHost!,
|
|
});
|
|
|
|
const startKeepalive = useCallback(() => {
|
|
if (!sshSessionId) return;
|
|
|
|
if (keepaliveTimerRef.current) {
|
|
clearInterval(keepaliveTimerRef.current);
|
|
}
|
|
|
|
keepaliveTimerRef.current = setInterval(async () => {
|
|
if (sshSessionId) {
|
|
try {
|
|
await keepSSHAlive(sshSessionId);
|
|
} catch (error) {
|
|
console.error("SSH keepalive failed:", error);
|
|
}
|
|
}
|
|
}, 30 * 1000);
|
|
}, [sshSessionId]);
|
|
|
|
const stopKeepalive = useCallback(() => {
|
|
if (keepaliveTimerRef.current) {
|
|
clearInterval(keepaliveTimerRef.current);
|
|
keepaliveTimerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const handleCloseWithError = useCallback(
|
|
(errorMessage: string) => {
|
|
setHasConnectionError(true);
|
|
addLog({
|
|
type: "error",
|
|
stage: "connection",
|
|
message: errorMessage,
|
|
});
|
|
},
|
|
[addLog],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (currentHost) {
|
|
initializeSSHConnection();
|
|
}
|
|
}, [currentHost]);
|
|
|
|
useEffect(() => {
|
|
if (sshSessionId && isVisible) {
|
|
startKeepalive();
|
|
} else {
|
|
stopKeepalive();
|
|
}
|
|
|
|
return () => {
|
|
stopKeepalive();
|
|
};
|
|
}, [sshSessionId, isVisible, startKeepalive, stopKeepalive]);
|
|
|
|
const initialFileOpenedRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!sshSessionId || !initialFilePath || initialFileOpenedRef.current)
|
|
return;
|
|
initialFileOpenedRef.current = true;
|
|
|
|
const fileName = initialFilePath.split("/").pop() || initialFilePath;
|
|
const fileDir =
|
|
initialFilePath.lastIndexOf("/") > 0
|
|
? initialFilePath.substring(0, initialFilePath.lastIndexOf("/"))
|
|
: "/";
|
|
|
|
setCurrentPath(fileDir);
|
|
|
|
const file: FileItem = {
|
|
name: fileName,
|
|
path: initialFilePath,
|
|
type: "file",
|
|
};
|
|
|
|
const windowCount = Date.now() % 10;
|
|
const offsetX = Math.min(
|
|
120 + windowCount * 30,
|
|
Math.max(0, window.innerWidth - 820),
|
|
);
|
|
const offsetY = Math.min(
|
|
120 + windowCount * 30,
|
|
Math.max(0, window.innerHeight - 620),
|
|
);
|
|
|
|
const createWindowComponent = (windowId: string) => (
|
|
<FileWindow
|
|
windowId={windowId}
|
|
file={file}
|
|
sshSessionId={sshSessionId}
|
|
sshHost={currentHost}
|
|
initialX={offsetX}
|
|
initialY={offsetY}
|
|
onFileNotFound={handleFileNotFound}
|
|
/>
|
|
);
|
|
|
|
openWindow({
|
|
title: fileName,
|
|
x: offsetX,
|
|
y: offsetY,
|
|
width: 800,
|
|
height: 600,
|
|
isMaximized: false,
|
|
isMinimized: false,
|
|
component: createWindowComponent,
|
|
});
|
|
}, [sshSessionId, initialFilePath]);
|
|
|
|
const initialLoadDoneRef = useRef(false);
|
|
const lastPathChangeRef = useRef<string>("");
|
|
const pathChangeTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
const currentLoadingPathRef = useRef<string>("");
|
|
const keepaliveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
const activityLoggedRef = useRef(false);
|
|
const activityLoggingRef = useRef(false);
|
|
|
|
const logFileManagerActivity = useCallback(async () => {
|
|
if (
|
|
!currentHost?.id ||
|
|
activityLoggedRef.current ||
|
|
activityLoggingRef.current
|
|
) {
|
|
return;
|
|
}
|
|
|
|
activityLoggingRef.current = true;
|
|
activityLoggedRef.current = true;
|
|
|
|
try {
|
|
const hostName =
|
|
currentHost.name || `${currentHost.username}@${currentHost.ip}`;
|
|
await logActivity("file_manager", currentHost.id, hostName);
|
|
} catch (err) {
|
|
console.warn("Failed to log file manager activity:", err);
|
|
activityLoggedRef.current = false;
|
|
} finally {
|
|
activityLoggingRef.current = false;
|
|
}
|
|
}, [currentHost]);
|
|
|
|
const handleFileDragStart = useCallback(
|
|
(files: FileItem[]) => {
|
|
systemDrag.startDragToSystem(files, {
|
|
enableToast: true,
|
|
onSuccess: () => {
|
|
clearSelection();
|
|
},
|
|
onError: (error) => {
|
|
console.error("Drag failed:", error);
|
|
},
|
|
});
|
|
},
|
|
[systemDrag, clearSelection],
|
|
);
|
|
|
|
const handleFileDragEnd = useCallback(
|
|
(e: DragEvent, draggedFiles: FileItem[]) => {
|
|
const isOutside =
|
|
e.clientX < 0 ||
|
|
e.clientX > window.innerWidth ||
|
|
e.clientY < 0 ||
|
|
e.clientY > window.innerHeight;
|
|
|
|
if (isOutside) {
|
|
if (draggedFiles.length === 0) {
|
|
console.error("No files to drag - this should not happen");
|
|
return;
|
|
}
|
|
|
|
systemDrag.startDragToSystem(draggedFiles, {
|
|
enableToast: true,
|
|
onSuccess: () => {
|
|
clearSelection();
|
|
},
|
|
onError: (error) => {
|
|
console.error("Drag failed:", error);
|
|
},
|
|
});
|
|
systemDrag.handleDragEnd(e);
|
|
} else {
|
|
systemDrag.cancelDragToSystem();
|
|
}
|
|
},
|
|
[systemDrag, clearSelection],
|
|
);
|
|
|
|
const isConnectingRef = useRef(false);
|
|
|
|
async function initializeSSHConnection() {
|
|
if (!currentHost || isConnectingRef.current) return;
|
|
|
|
if (currentHost.enableSsh === false) {
|
|
setHasConnectionError(true);
|
|
addLog({
|
|
type: "error",
|
|
message: t("fileManager.sshRequiredForFileManager"),
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
isConnectingRef.current = true;
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
initialLoadDoneRef.current = false;
|
|
setHasConnectionError(false);
|
|
clearLogs();
|
|
|
|
const sessionId = currentHost.id.toString();
|
|
|
|
const result = await connectSSH(sessionId, {
|
|
hostId: currentHost.id,
|
|
ip: currentHost.ip,
|
|
port: currentHost.port,
|
|
username: currentHost.username,
|
|
password: currentHost.password,
|
|
sshKey: currentHost.key,
|
|
keyPassword: currentHost.keyPassword,
|
|
authType: currentHost.authType,
|
|
credentialId: currentHost.credentialId,
|
|
userId: currentHost.userId,
|
|
forceKeyboardInteractive: currentHost.forceKeyboardInteractive,
|
|
jumpHosts: currentHost.jumpHosts,
|
|
useSocks5: currentHost.useSocks5,
|
|
socks5Host: currentHost.socks5Host,
|
|
socks5Port: currentHost.socks5Port,
|
|
socks5Username: currentHost.socks5Username,
|
|
socks5Password: currentHost.socks5Password,
|
|
socks5ProxyChain: currentHost.socks5ProxyChain,
|
|
});
|
|
|
|
if (result?.requires_warpgate) {
|
|
setWarpgateRequired(true);
|
|
setWarpgateSessionId(sessionId);
|
|
setWarpgateUrl(result.url || "");
|
|
setWarpgateSecurityKey(result.securityKey || "N/A");
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (result?.requires_totp) {
|
|
setTotpRequired(true);
|
|
setTotpSessionId(sessionId);
|
|
setTotpPrompt(result.prompt || t("fileManager.verificationCodePrompt"));
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (result?.status === "auth_required") {
|
|
setAuthDialogReason(result.reason || "no_keyboard");
|
|
setShowAuthDialog(true);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (result?.status === "passphrase_required") {
|
|
setShowPassphraseDialog(true);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
setSshSessionId(sessionId);
|
|
|
|
try {
|
|
const response = await listSSHFiles(sessionId, currentPath);
|
|
const files = Array.isArray(response)
|
|
? response
|
|
: response?.files || [];
|
|
setFiles(files);
|
|
clearSelection();
|
|
initialLoadDoneRef.current = true;
|
|
|
|
if (!result?.requires_totp) {
|
|
logFileManagerActivity();
|
|
}
|
|
} catch (dirError: unknown) {
|
|
console.error("Failed to load initial directory:", dirError);
|
|
}
|
|
} catch (error: unknown) {
|
|
const sshError = error as SSHConnectionError;
|
|
console.error("SSH connection failed:", error);
|
|
|
|
if (sshError.connectionLogs) {
|
|
sshError.connectionLogs.forEach((log) => {
|
|
addLog({
|
|
type: log.type,
|
|
stage: log.stage,
|
|
message: log.message,
|
|
details: log.details,
|
|
});
|
|
});
|
|
if (sshError.requires_totp) {
|
|
setTotpRequired(true);
|
|
setTotpSessionId(sshError.sessionId || currentHost.id.toString());
|
|
setTotpPrompt(
|
|
sshError.prompt || t("fileManager.verificationCodePrompt"),
|
|
);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
if (sshError.requires_warpgate) {
|
|
setWarpgateRequired(true);
|
|
setWarpgateSessionId(sshError.sessionId || currentHost.id.toString());
|
|
setWarpgateUrl(sshError.url || "");
|
|
setWarpgateSecurityKey(sshError.securityKey || "N/A");
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
if (sshError.status === "auth_required") {
|
|
setAuthDialogReason(sshError.reason || "no_keyboard");
|
|
setShowAuthDialog(true);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
} else {
|
|
addLog({
|
|
type: "error",
|
|
stage: "connection",
|
|
message:
|
|
error instanceof Error
|
|
? error.message
|
|
: t("fileManager.failedToConnect"),
|
|
});
|
|
}
|
|
|
|
handleCloseWithError(
|
|
t("fileManager.failedToConnect") +
|
|
": " +
|
|
(error instanceof Error ? error.message : String(error)),
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
isConnectingRef.current = false;
|
|
}
|
|
}
|
|
|
|
const loadDirectory = useCallback(
|
|
async (path: string): Promise<boolean> => {
|
|
if (!sshSessionId) {
|
|
console.error("Cannot load directory: no SSH session ID");
|
|
return false;
|
|
}
|
|
|
|
let resolvedPath = path;
|
|
if (path.includes("$") || path.startsWith("~")) {
|
|
resolvedPath = await resolveSSHPath(sshSessionId, path);
|
|
if (resolvedPath !== path) {
|
|
setCurrentPath(resolvedPath);
|
|
lastPathChangeRef.current = resolvedPath;
|
|
}
|
|
}
|
|
|
|
currentLoadingPathRef.current = resolvedPath;
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const response = await listSSHFiles(sshSessionId, resolvedPath);
|
|
|
|
if (currentLoadingPathRef.current !== resolvedPath) {
|
|
return false;
|
|
}
|
|
|
|
const files = Array.isArray(response)
|
|
? response
|
|
: response?.files || [];
|
|
|
|
setFiles(files);
|
|
clearSelection();
|
|
return true;
|
|
} catch (error: unknown) {
|
|
if (currentLoadingPathRef.current === resolvedPath) {
|
|
// ApiError has .status directly; raw axios errors have .response.status
|
|
const apiError = error as {
|
|
status?: number;
|
|
code?: string;
|
|
response?: {
|
|
status?: number;
|
|
data?: {
|
|
needsSudo?: boolean;
|
|
error?: string;
|
|
sudoFailed?: boolean;
|
|
disconnected?: boolean;
|
|
};
|
|
};
|
|
message?: string;
|
|
};
|
|
|
|
const httpStatus = apiError.status ?? apiError.response?.status;
|
|
|
|
// 409 = concurrent request already in flight — silently drop
|
|
if (httpStatus === 409) {
|
|
return false;
|
|
}
|
|
|
|
if (apiError.response?.data?.needsSudo) {
|
|
if (!sudoDialogOpen) {
|
|
setPendingSudoOperation({ type: "navigate", path: resolvedPath });
|
|
setSudoDialogOpen(true);
|
|
}
|
|
|
|
if (apiError.response.data.sudoFailed) {
|
|
toast.error(t("fileManager.sudoAuthFailed"));
|
|
} else {
|
|
toast.error(t("fileManager.permissionDenied"));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
console.error("Failed to load directory:", error);
|
|
|
|
const errorMessage =
|
|
apiError.response?.data?.error || apiError.message || String(error);
|
|
|
|
const isConnectionError =
|
|
// 500s from the file manager are SSH channel/session errors
|
|
httpStatus === 500 ||
|
|
httpStatus === 503 ||
|
|
apiError.response?.data?.disconnected === true ||
|
|
errorMessage?.includes("channel open failure") ||
|
|
errorMessage?.includes("open failed") ||
|
|
errorMessage?.includes("SSH connection not established") ||
|
|
errorMessage?.includes("SSH session") ||
|
|
errorMessage?.toLowerCase().includes("not connected");
|
|
|
|
if (isConnectionError && sshSessionId && currentHost) {
|
|
setIsReconnecting(true);
|
|
setIsLoading(false);
|
|
setFiles([]);
|
|
currentLoadingPathRef.current = "";
|
|
|
|
void (async () => {
|
|
const delays = [1000, 2000, 3000, 5000, 5000];
|
|
for (let attempt = 0; attempt < delays.length; attempt++) {
|
|
await new Promise((r) => setTimeout(r, delays[attempt]));
|
|
try {
|
|
await ensureSSHConnection();
|
|
setIsReconnecting(false);
|
|
loadDirectory(resolvedPath);
|
|
return;
|
|
} catch {
|
|
// keep retrying
|
|
}
|
|
}
|
|
setIsReconnecting(false);
|
|
handleCloseWithError(
|
|
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
|
|
);
|
|
})();
|
|
|
|
return false;
|
|
} else if (initialLoadDoneRef.current) {
|
|
const isPermissionDenied =
|
|
httpStatus === 403 ||
|
|
errorMessage?.toLowerCase().includes("permission denied") ||
|
|
errorMessage?.toLowerCase().includes("eacces");
|
|
if (isPermissionDenied) {
|
|
toast.error(t("fileManager.permissionDenied"));
|
|
} else {
|
|
toast.error(
|
|
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
} finally {
|
|
if (currentLoadingPathRef.current === resolvedPath) {
|
|
setIsLoading(false);
|
|
currentLoadingPathRef.current = "";
|
|
}
|
|
}
|
|
},
|
|
[sshSessionId, isLoading, clearSelection, t, sudoDialogOpen, currentHost],
|
|
);
|
|
|
|
const debouncedLoadDirectory = useCallback(
|
|
(path: string, force?: boolean) => {
|
|
if (pathChangeTimerRef.current) {
|
|
clearTimeout(pathChangeTimerRef.current);
|
|
}
|
|
|
|
pathChangeTimerRef.current = setTimeout(() => {
|
|
if ((force || path !== lastPathChangeRef.current) && sshSessionId) {
|
|
lastPathChangeRef.current = path;
|
|
loadDirectory(path);
|
|
}
|
|
}, 150);
|
|
},
|
|
[sshSessionId, loadDirectory],
|
|
);
|
|
|
|
const navigateTo = useCallback(
|
|
(path: string) => {
|
|
if (sshSessionId) setIsLoading(true);
|
|
setCurrentPath(path);
|
|
setNavHistory((prev) => {
|
|
const next = [...prev.slice(0, navIndex + 1), path];
|
|
setNavIndex(next.length - 1);
|
|
return next;
|
|
});
|
|
},
|
|
[navIndex, sshSessionId],
|
|
);
|
|
|
|
const goBack = useCallback(() => {
|
|
if (navIndex > 0) {
|
|
if (sshSessionId) setIsLoading(true);
|
|
const newIndex = navIndex - 1;
|
|
setNavIndex(newIndex);
|
|
setCurrentPath(navHistory[newIndex]);
|
|
}
|
|
}, [navIndex, navHistory, sshSessionId]);
|
|
|
|
const goForward = useCallback(() => {
|
|
if (navIndex < navHistory.length - 1) {
|
|
if (sshSessionId) setIsLoading(true);
|
|
const newIndex = navIndex + 1;
|
|
setNavIndex(newIndex);
|
|
setCurrentPath(navHistory[newIndex]);
|
|
}
|
|
}, [navIndex, navHistory, sshSessionId]);
|
|
|
|
const goUp = useCallback(() => {
|
|
if (currentPath === "/") return;
|
|
const parent =
|
|
currentPath.substring(0, currentPath.lastIndexOf("/")) || "/";
|
|
navigateTo(parent);
|
|
}, [currentPath, navigateTo]);
|
|
|
|
useEffect(() => {
|
|
if (sshSessionId && currentPath) {
|
|
if (!initialLoadDoneRef.current) {
|
|
initialLoadDoneRef.current = true;
|
|
lastPathChangeRef.current = currentPath;
|
|
return;
|
|
}
|
|
|
|
debouncedLoadDirectory(currentPath);
|
|
}
|
|
|
|
return () => {
|
|
if (pathChangeTimerRef.current) {
|
|
clearTimeout(pathChangeTimerRef.current);
|
|
}
|
|
};
|
|
}, [sshSessionId, currentPath, debouncedLoadDirectory]);
|
|
|
|
const handleRefreshDirectory = useCallback(() => {
|
|
const now = Date.now();
|
|
const DEBOUNCE_MS = 500;
|
|
|
|
if (now - lastRefreshTime < DEBOUNCE_MS) {
|
|
return;
|
|
}
|
|
|
|
setLastRefreshTime(now);
|
|
// Force reset loading state to ensure refresh is not blocked
|
|
setIsLoading(false);
|
|
currentLoadingPathRef.current = "";
|
|
loadDirectory(currentPath);
|
|
}, [currentPath, lastRefreshTime, loadDirectory]);
|
|
|
|
useEffect(() => {
|
|
const handler = (event: Event) => {
|
|
const detail = (event as CustomEvent<{ hostId: number; path: string }>)
|
|
.detail;
|
|
if (!detail || !currentHost?.id) return;
|
|
if (detail.hostId === currentHost.id) {
|
|
handleRefreshDirectory();
|
|
}
|
|
};
|
|
window.addEventListener("file-manager:refresh", handler);
|
|
return () => window.removeEventListener("file-manager:refresh", handler);
|
|
}, [currentHost?.id, handleRefreshDirectory]);
|
|
|
|
useEffect(() => {
|
|
const handler = (event: Event) => {
|
|
const detail = (
|
|
event as CustomEvent<{
|
|
files: FileItem[];
|
|
operation: "copy" | "cut";
|
|
sourceHostId: number | null;
|
|
sourceSessionId: string | null;
|
|
}>
|
|
).detail;
|
|
if (!detail) return;
|
|
setClipboard(detail);
|
|
};
|
|
window.addEventListener("file-manager:clipboard", handler);
|
|
return () => window.removeEventListener("file-manager:clipboard", handler);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
const activeElement = document.activeElement;
|
|
if (
|
|
activeElement &&
|
|
(activeElement.tagName === "INPUT" ||
|
|
activeElement.tagName === "TEXTAREA" ||
|
|
activeElement.contentEditable === "true")
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (event.key === "T" && event.ctrlKey && event.shiftKey) {
|
|
event.preventDefault();
|
|
handleOpenTerminal(currentPath);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
}, [currentPath]);
|
|
|
|
async function handleItemsDropped(items: DataTransferItemList) {
|
|
if (!sshSessionId) {
|
|
toast.error(t("fileManager.noSSHConnection"));
|
|
return;
|
|
}
|
|
|
|
const entries: FileSystemEntry[] = [];
|
|
for (let i = 0; i < items.length; i++) {
|
|
const entry = items[i].webkitGetAsEntry?.();
|
|
if (entry) entries.push(entry);
|
|
}
|
|
|
|
const files: { file: File; relativePath: string }[] = [];
|
|
|
|
async function readEntry(
|
|
entry: FileSystemEntry,
|
|
path: string,
|
|
): Promise<void> {
|
|
if (entry.isFile) {
|
|
const file = await new Promise<File>((resolve, reject) =>
|
|
(entry as FileSystemFileEntry).file(resolve, reject),
|
|
);
|
|
files.push({ file, relativePath: path });
|
|
} else if (entry.isDirectory) {
|
|
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
|
let batch: FileSystemEntry[];
|
|
do {
|
|
batch = await new Promise<FileSystemEntry[]>((resolve, reject) =>
|
|
reader.readEntries(resolve, reject),
|
|
);
|
|
for (const child of batch) {
|
|
await readEntry(child, `${path}/${child.name}`);
|
|
}
|
|
} while (batch.length > 0);
|
|
}
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
await readEntry(entry, entry.name);
|
|
}
|
|
|
|
if (files.length === 0) return;
|
|
|
|
const progressToast = toast.loading(
|
|
`Uploading ${files.length} file(s)...`,
|
|
{ duration: Infinity },
|
|
);
|
|
|
|
try {
|
|
await ensureSSHConnection();
|
|
|
|
const dirs = new Set<string>();
|
|
for (const { relativePath } of files) {
|
|
const parts = relativePath.split("/");
|
|
for (let i = 1; i < parts.length; i++) {
|
|
dirs.add(parts.slice(0, i).join("/"));
|
|
}
|
|
}
|
|
|
|
const sortedDirs = Array.from(dirs).sort();
|
|
for (const dir of sortedDirs) {
|
|
const parentPath = currentPath.endsWith("/")
|
|
? currentPath + dir.split("/").slice(0, -1).join("/")
|
|
: currentPath + "/" + dir.split("/").slice(0, -1).join("/");
|
|
const folderName = dir.split("/").pop()!;
|
|
const targetPath = parentPath.endsWith("/")
|
|
? parentPath
|
|
: parentPath + "/";
|
|
try {
|
|
await createSSHFolder(
|
|
sshSessionId,
|
|
targetPath,
|
|
folderName,
|
|
currentHost?.id,
|
|
);
|
|
} catch {
|
|
// directory may already exist
|
|
}
|
|
}
|
|
|
|
for (const { file, relativePath } of files) {
|
|
const dirPart = relativePath.includes("/")
|
|
? relativePath.substring(0, relativePath.lastIndexOf("/"))
|
|
: "";
|
|
const uploadPath = dirPart
|
|
? (currentPath.endsWith("/") ? currentPath : currentPath + "/") +
|
|
dirPart +
|
|
"/"
|
|
: currentPath;
|
|
|
|
await uploadSSHFile(
|
|
sshSessionId,
|
|
uploadPath,
|
|
file.name,
|
|
file,
|
|
currentHost?.id,
|
|
);
|
|
}
|
|
|
|
toast.dismiss(progressToast);
|
|
toast.success(`Uploaded ${files.length} file(s) successfully`);
|
|
handleRefreshDirectory();
|
|
} catch (error) {
|
|
toast.dismiss(progressToast);
|
|
toast.error(t("fileManager.failedToUploadFile"));
|
|
console.error("Folder upload failed:", error);
|
|
}
|
|
}
|
|
|
|
function handleFilesDropped(fileList: FileList) {
|
|
if (!sshSessionId) {
|
|
toast.error(t("fileManager.noSSHConnection"));
|
|
return;
|
|
}
|
|
|
|
Array.from(fileList).forEach((file) => {
|
|
handleUploadFile(file);
|
|
});
|
|
}
|
|
|
|
async function handleUploadFile(file: File) {
|
|
if (!sshSessionId) return;
|
|
|
|
const progressToast = toast.loading(
|
|
t("fileManager.uploadingFile", {
|
|
name: file.name,
|
|
size: formatFileSize(file.size),
|
|
}),
|
|
{ duration: Infinity },
|
|
);
|
|
|
|
const updateProgress = (p: {
|
|
chunkIndex: number;
|
|
totalChunks: number;
|
|
bytesSent: number;
|
|
totalBytes: number;
|
|
}) => {
|
|
const percent = Math.min(
|
|
100,
|
|
Math.round((p.bytesSent / p.totalBytes) * 100),
|
|
);
|
|
toast.loading(
|
|
`Uploading ${file.name} — ${percent}% (chunk ${p.chunkIndex + 1}/${p.totalChunks})`,
|
|
{ id: progressToast, duration: Infinity },
|
|
);
|
|
};
|
|
|
|
try {
|
|
await ensureSSHConnection();
|
|
|
|
await uploadSSHFile(
|
|
sshSessionId,
|
|
currentPath,
|
|
file.name,
|
|
file,
|
|
currentHost?.id,
|
|
undefined,
|
|
updateProgress,
|
|
);
|
|
|
|
toast.dismiss(progressToast);
|
|
|
|
toast.success(
|
|
t("fileManager.fileUploadedSuccessfully", { name: file.name }),
|
|
);
|
|
handleRefreshDirectory();
|
|
} catch (error: unknown) {
|
|
toast.dismiss(progressToast);
|
|
const uploadErr = error instanceof Error ? error : null;
|
|
if (
|
|
uploadErr?.message?.includes("connection") ||
|
|
uploadErr?.message?.includes("established")
|
|
) {
|
|
toast.error(
|
|
t("fileManager.sshConnectionFailed", {
|
|
name: currentHost?.name,
|
|
ip: currentHost?.ip,
|
|
port: currentHost?.port,
|
|
}),
|
|
);
|
|
} else {
|
|
toast.error(t("fileManager.failedToUploadFile"));
|
|
}
|
|
console.error("Upload failed:", error);
|
|
}
|
|
}
|
|
|
|
async function handleDownloadFile(file: FileItem) {
|
|
if (!sshSessionId) return;
|
|
|
|
try {
|
|
await ensureSSHConnection();
|
|
|
|
const { downloadSSHFileStream } = await import("@/main-axios.ts");
|
|
await downloadSSHFileStream(sshSessionId, file.path);
|
|
|
|
toast.success(
|
|
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
|
|
);
|
|
} catch (error: unknown) {
|
|
const err = error instanceof Error ? error : null;
|
|
if (
|
|
err?.message?.includes("connection") ||
|
|
err?.message?.includes("established")
|
|
) {
|
|
toast.error(
|
|
t("fileManager.sshConnectionFailed", {
|
|
name: currentHost?.name,
|
|
ip: currentHost?.ip,
|
|
port: currentHost?.port,
|
|
}),
|
|
);
|
|
} else {
|
|
toast.error(t("fileManager.failedToDownloadFile"));
|
|
}
|
|
console.error("Download failed:", error);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteFiles(files: FileItem[]) {
|
|
if (!sshSessionId || files.length === 0) return;
|
|
|
|
let confirmMessage: string;
|
|
if (files.length === 1) {
|
|
const file = files[0];
|
|
if (file.type === "directory") {
|
|
confirmMessage = t("fileManager.confirmDeleteFolder", {
|
|
name: file.name,
|
|
});
|
|
} else {
|
|
confirmMessage = t("fileManager.confirmDeleteSingleItem", {
|
|
name: file.name,
|
|
});
|
|
}
|
|
} else {
|
|
const hasDirectory = files.some((file) => file.type === "directory");
|
|
const translationKey = hasDirectory
|
|
? "fileManager.confirmDeleteMultipleItemsWithFolders"
|
|
: "fileManager.confirmDeleteMultipleItems";
|
|
|
|
confirmMessage = t(translationKey, {
|
|
count: files.length,
|
|
});
|
|
}
|
|
|
|
const fullMessage = `${confirmMessage}\n\n${t("fileManager.permanentDeleteWarning")}`;
|
|
|
|
confirmWithToast(
|
|
fullMessage,
|
|
async () => {
|
|
try {
|
|
await ensureSSHConnection();
|
|
|
|
for (const file of files) {
|
|
await deleteSSHItem(
|
|
sshSessionId,
|
|
file.path,
|
|
file.type === "directory",
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
}
|
|
|
|
const deletedFiles = files.map((file) => ({
|
|
path: file.path,
|
|
name: file.name,
|
|
}));
|
|
|
|
const undoAction: UndoAction = {
|
|
type: "delete",
|
|
description: t("fileManager.deletedItems", { count: files.length }),
|
|
data: {
|
|
operation: "cut",
|
|
deletedFiles,
|
|
targetDirectory: currentPath,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
setUndoHistory((prev) => [...prev.slice(-9), undoAction]);
|
|
|
|
toast.success(
|
|
t("fileManager.itemsDeletedSuccessfully", { count: files.length }),
|
|
);
|
|
handleRefreshDirectory();
|
|
clearSelection();
|
|
} catch (error: unknown) {
|
|
const axiosError = error as {
|
|
response?: {
|
|
data?: { needsSudo?: boolean; error?: string };
|
|
status?: number;
|
|
};
|
|
message?: string;
|
|
};
|
|
if (axiosError.response?.data?.needsSudo) {
|
|
setPendingSudoOperation({ type: "delete", files });
|
|
setSudoDialogOpen(true);
|
|
return;
|
|
}
|
|
if (
|
|
axiosError.response?.status === 403 ||
|
|
axiosError.response?.data?.error
|
|
?.toLowerCase()
|
|
.includes("permission denied")
|
|
) {
|
|
toast.error(t("fileManager.permissionDenied"));
|
|
} else if (
|
|
axiosError.message?.includes("connection") ||
|
|
axiosError.message?.includes("established")
|
|
) {
|
|
toast.error(
|
|
t("fileManager.sshConnectionFailed", {
|
|
name: currentHost?.name,
|
|
ip: currentHost?.ip,
|
|
port: currentHost?.port,
|
|
}),
|
|
);
|
|
} else {
|
|
toast.error(t("fileManager.failedToDeleteItems"));
|
|
}
|
|
console.error("Delete failed:", error);
|
|
}
|
|
},
|
|
"destructive",
|
|
);
|
|
}
|
|
|
|
async function handleSudoPasswordSubmit(password: string) {
|
|
if (!sshSessionId || !pendingSudoOperation) return;
|
|
|
|
try {
|
|
await setSudoPassword(sshSessionId, password);
|
|
setSudoDialogOpen(false);
|
|
|
|
if (pendingSudoOperation.type === "delete") {
|
|
for (const file of pendingSudoOperation.files) {
|
|
await deleteSSHItem(
|
|
sshSessionId,
|
|
file.path,
|
|
file.type === "directory",
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
}
|
|
toast.success(
|
|
t("fileManager.itemsDeletedSuccessfully", {
|
|
count: pendingSudoOperation.files.length,
|
|
}),
|
|
);
|
|
handleRefreshDirectory();
|
|
clearSelection();
|
|
} else if (pendingSudoOperation.type === "navigate") {
|
|
const success = await loadDirectory(pendingSudoOperation.path);
|
|
if (success) {
|
|
setCurrentPath(pendingSudoOperation.path);
|
|
setPendingSudoOperation(null);
|
|
}
|
|
return;
|
|
}
|
|
|
|
setPendingSudoOperation(null);
|
|
} catch (error: unknown) {
|
|
const axiosError = error as {
|
|
response?: { data?: { needsSudo?: boolean; sudoFailed?: boolean } };
|
|
message?: string;
|
|
};
|
|
|
|
if (axiosError.response?.data?.sudoFailed) {
|
|
toast.error(t("fileManager.sudoAuthFailed"));
|
|
setSudoDialogOpen(true);
|
|
return;
|
|
}
|
|
|
|
toast.error(axiosError.message || t("fileManager.sudoOperationFailed"));
|
|
setPendingSudoOperation(null);
|
|
}
|
|
}
|
|
|
|
function handleCreateNewFolder() {
|
|
const defaultName = generateUniqueName(
|
|
t("fileManager.newFolderDefault"),
|
|
"directory",
|
|
);
|
|
setCreateIntent({
|
|
id: Date.now().toString(),
|
|
type: "directory" as const,
|
|
defaultName,
|
|
currentName: defaultName,
|
|
});
|
|
}
|
|
|
|
function handleCreateNewFile() {
|
|
const defaultName = generateUniqueName(
|
|
t("fileManager.newFileDefault"),
|
|
"file",
|
|
);
|
|
setCreateIntent({
|
|
id: Date.now().toString(),
|
|
type: "file" as const,
|
|
defaultName,
|
|
currentName: defaultName,
|
|
});
|
|
}
|
|
|
|
const handleSymlinkClick = async (file: FileItem) => {
|
|
if (!currentHost || !sshSessionId) {
|
|
toast.error(t("fileManager.noSSHConnection"));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const currentSessionId = sshSessionId;
|
|
const status = await getSSHStatus(currentSessionId);
|
|
if (!status.connected) {
|
|
const result = await connectSSH(currentSessionId, {
|
|
hostId: currentHost.id,
|
|
ip: currentHost.ip,
|
|
port: currentHost.port,
|
|
username: currentHost.username,
|
|
authType: currentHost.authType,
|
|
password: currentHost.password,
|
|
sshKey: currentHost.key,
|
|
keyPassword: currentHost.keyPassword,
|
|
credentialId: currentHost.credentialId,
|
|
jumpHosts: currentHost.jumpHosts,
|
|
useSocks5: currentHost.useSocks5,
|
|
socks5Host: currentHost.socks5Host,
|
|
socks5Port: currentHost.socks5Port,
|
|
socks5Username: currentHost.socks5Username,
|
|
socks5Password: currentHost.socks5Password,
|
|
socks5ProxyChain: currentHost.socks5ProxyChain,
|
|
});
|
|
|
|
if (!result.success) {
|
|
throw new Error(t("fileManager.failedToReconnectSSH"));
|
|
}
|
|
}
|
|
|
|
const symlinkInfo = await identifySSHSymlink(currentSessionId, file.path);
|
|
|
|
if (symlinkInfo.type === "directory") {
|
|
setCurrentPath(symlinkInfo.target);
|
|
} else if (symlinkInfo.type === "file") {
|
|
const windowCount = Date.now() % 10;
|
|
const offsetX = 120 + windowCount * 30;
|
|
const offsetY = 120 + windowCount * 30;
|
|
|
|
const targetFile: FileItem = {
|
|
...file,
|
|
path: symlinkInfo.target,
|
|
};
|
|
|
|
const createWindowComponent = (windowId: string) => (
|
|
<FileWindow
|
|
windowId={windowId}
|
|
file={targetFile}
|
|
sshSessionId={currentSessionId}
|
|
sshHost={currentHost}
|
|
initialX={offsetX}
|
|
initialY={offsetY}
|
|
/>
|
|
);
|
|
|
|
openWindow({
|
|
title: file.name,
|
|
x: offsetX,
|
|
y: offsetY,
|
|
width: 800,
|
|
height: 600,
|
|
isMaximized: false,
|
|
isMinimized: false,
|
|
component: createWindowComponent,
|
|
});
|
|
}
|
|
} catch (error: unknown) {
|
|
toast.error(
|
|
error?.response?.data?.error ||
|
|
error?.message ||
|
|
t("fileManager.failedToResolveSymlink"),
|
|
);
|
|
}
|
|
};
|
|
|
|
function openFileWindow(file: FileItem, sessionId: string) {
|
|
const windowCount = Date.now() % 10;
|
|
const baseOffsetX = 120 + windowCount * 30;
|
|
const baseOffsetY = 120 + windowCount * 30;
|
|
|
|
const maxOffsetX = Math.max(0, window.innerWidth - 800 - 100);
|
|
const maxOffsetY = Math.max(0, window.innerHeight - 600 - 100);
|
|
|
|
const offsetX = Math.min(baseOffsetX, maxOffsetX);
|
|
const offsetY = Math.min(baseOffsetY, maxOffsetY);
|
|
|
|
const createWindowComponent = (windowId: string) => (
|
|
<FileWindow
|
|
windowId={windowId}
|
|
file={file}
|
|
sshSessionId={sessionId}
|
|
sshHost={currentHost}
|
|
initialX={offsetX}
|
|
initialY={offsetY}
|
|
onFileNotFound={handleFileNotFound}
|
|
/>
|
|
);
|
|
|
|
openWindow({
|
|
title: file.name,
|
|
x: offsetX,
|
|
y: offsetY,
|
|
width: 800,
|
|
height: 600,
|
|
isMaximized: false,
|
|
isMinimized: false,
|
|
component: createWindowComponent,
|
|
});
|
|
}
|
|
|
|
async function confirmLargeFileOpen(file: FileItem) {
|
|
if (!file.size || file.size <= LARGE_FILE_WARNING_SIZE) return true;
|
|
|
|
return confirmWithToast(
|
|
{
|
|
title: t("fileManager.largeFileWarning"),
|
|
description: t("fileManager.largeFileWarningDesc", {
|
|
size: formatFileSize(file.size),
|
|
}),
|
|
confirmText: t("fileManager.confirm"),
|
|
cancelText: t("common.cancel"),
|
|
},
|
|
undefined,
|
|
"default",
|
|
t("common.cancel"),
|
|
{ duration: 12000 },
|
|
);
|
|
}
|
|
|
|
async function handleFileOpen(file: FileItem) {
|
|
if (file.type === "directory") {
|
|
if (sshSessionId) setIsLoading(true);
|
|
setCurrentPath(file.path);
|
|
return;
|
|
}
|
|
|
|
if (file.type === "link") {
|
|
await handleSymlinkClick(file);
|
|
return;
|
|
}
|
|
|
|
if (!sshSessionId) {
|
|
toast.error(t("fileManager.noSSHConnection"));
|
|
return;
|
|
}
|
|
|
|
if (!(await confirmLargeFileOpen(file))) return;
|
|
|
|
await recordRecentFile(file);
|
|
openFileWindow(file, sshSessionId);
|
|
}
|
|
|
|
function handleContextMenu(event: React.MouseEvent, file?: FileItem) {
|
|
event.preventDefault();
|
|
|
|
let files: FileItem[];
|
|
if (file) {
|
|
const isFileSelected = selectedFiles.some((f) => f.path === file.path);
|
|
files = isFileSelected ? selectedFiles : [file];
|
|
} else {
|
|
files = selectedFiles;
|
|
}
|
|
|
|
setContextMenu({
|
|
x: event.clientX,
|
|
y: event.clientY,
|
|
isVisible: true,
|
|
files,
|
|
});
|
|
}
|
|
|
|
function handleSidebarItemContextMenu(
|
|
event: React.MouseEvent,
|
|
item: SidebarItem,
|
|
) {
|
|
const file: FileItem = {
|
|
name: item.name,
|
|
path: item.path,
|
|
type:
|
|
item.type === "recent" || item.type === "pinned" ? "file" : "directory",
|
|
};
|
|
handleContextMenu(event, file);
|
|
}
|
|
|
|
function handleCopyFiles(files: FileItem[]) {
|
|
const entry = {
|
|
files,
|
|
operation: "copy" as const,
|
|
sourceHostId: currentHost?.id ?? null,
|
|
sourceSessionId: sshSessionId,
|
|
};
|
|
setClipboard(entry);
|
|
window.dispatchEvent(
|
|
new CustomEvent("file-manager:clipboard", { detail: entry }),
|
|
);
|
|
toast.success(
|
|
t("fileManager.filesCopiedToClipboard", { count: files.length }),
|
|
);
|
|
}
|
|
|
|
function handleCutFiles(files: FileItem[]) {
|
|
const entry = {
|
|
files,
|
|
operation: "cut" as const,
|
|
sourceHostId: currentHost?.id ?? null,
|
|
sourceSessionId: sshSessionId,
|
|
};
|
|
setClipboard(entry);
|
|
window.dispatchEvent(
|
|
new CustomEvent("file-manager:clipboard", { detail: entry }),
|
|
);
|
|
toast.success(
|
|
t("fileManager.filesCutToClipboard", { count: files.length }),
|
|
);
|
|
}
|
|
|
|
function handleCopyPath(files: FileItem[]) {
|
|
if (files.length === 0) return;
|
|
|
|
const paths = files.map((file) => file.path).join("\n");
|
|
|
|
copyToClipboard(paths).then((ok) => {
|
|
if (ok) {
|
|
toast.success(
|
|
files.length === 1
|
|
? t("fileManager.pathCopiedToClipboard")
|
|
: t("fileManager.pathsCopiedToClipboard", { count: files.length }),
|
|
);
|
|
} else {
|
|
toast.error(t("fileManager.failedToCopyPath"));
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleCopyFolderLink(path: string) {
|
|
if (!currentHost?.id) return;
|
|
const params = new URLSearchParams({
|
|
view: "file-manager",
|
|
hostId: String(currentHost.id),
|
|
path,
|
|
});
|
|
const url = `${window.location.origin}?${params.toString()}`;
|
|
copyToClipboard(url).then((ok) => {
|
|
if (ok) {
|
|
toast.success(t("fileManager.folderLinkCopied"));
|
|
} else {
|
|
toast.error(t("fileManager.failedToCopyFolderLink"));
|
|
}
|
|
});
|
|
}
|
|
|
|
async function handleCrossHostPaste() {
|
|
if (!clipboard || !sshSessionId || !currentHost) return;
|
|
|
|
const { files, operation, sourceSessionId } = clipboard;
|
|
if (!sourceSessionId) return;
|
|
|
|
const sourcePaths = files.map((f) => f.path);
|
|
|
|
try {
|
|
const { transferId } = await transferToHost(
|
|
sourceSessionId,
|
|
sourcePaths,
|
|
sshSessionId,
|
|
currentPath,
|
|
operation === "cut",
|
|
"auto",
|
|
2,
|
|
);
|
|
|
|
const monitorHandle = beginTransferProgressMonitoring(transferId, t, {
|
|
formatTransferMetrics,
|
|
});
|
|
if (!monitorHandle) return;
|
|
|
|
const finalStatus = await monitorHandle.waitForCompletion;
|
|
|
|
if (
|
|
finalStatus.status !== "success" &&
|
|
finalStatus.status !== "partial"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (operation === "cut") {
|
|
setClipboard(null);
|
|
}
|
|
|
|
handleRefreshDirectory();
|
|
clearSelection();
|
|
} catch (error: unknown) {
|
|
const err = error as { message?: string };
|
|
toast.error(
|
|
`${t("transfer.transferError")}: ${err.message || t("fileManager.unknownError")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function handlePasteFiles() {
|
|
if (!clipboard || !sshSessionId) return;
|
|
|
|
if (
|
|
clipboard.sourceHostId !== null &&
|
|
clipboard.sourceHostId !== currentHost?.id
|
|
) {
|
|
await handleCrossHostPaste();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await ensureSSHConnection();
|
|
|
|
const { files, operation } = clipboard;
|
|
|
|
let successCount = 0;
|
|
const copiedItems: string[] = [];
|
|
|
|
for (const file of files) {
|
|
try {
|
|
if (operation === "copy") {
|
|
const result = await copySSHItem(
|
|
sshSessionId,
|
|
file.path,
|
|
currentPath,
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
copiedItems.push(result.uniqueName || file.name);
|
|
successCount++;
|
|
} else {
|
|
const targetPath = currentPath.endsWith("/")
|
|
? `${currentPath}${file.name}`
|
|
: `${currentPath}/${file.name}`;
|
|
|
|
if (file.path !== targetPath) {
|
|
await moveSSHItem(
|
|
sshSessionId,
|
|
file.path,
|
|
targetPath,
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
successCount++;
|
|
}
|
|
}
|
|
} catch (error: unknown) {
|
|
console.error(`Failed to ${operation} file ${file.name}:`, error);
|
|
const axiosError = error as {
|
|
response?: { status?: number; data?: { error?: string } };
|
|
};
|
|
if (
|
|
axiosError.response?.status === 403 ||
|
|
axiosError.response?.data?.error
|
|
?.toLowerCase()
|
|
.includes("permission denied")
|
|
) {
|
|
toast.error(t("fileManager.permissionDenied"));
|
|
} else {
|
|
toast.error(
|
|
t("fileManager.operationFailed", {
|
|
operation:
|
|
operation === "copy"
|
|
? t("fileManager.copy")
|
|
: t("fileManager.move"),
|
|
name: file.name,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (successCount > 0) {
|
|
if (operation === "copy") {
|
|
const copiedFiles = files
|
|
.slice(0, successCount)
|
|
.map((file, index) => ({
|
|
originalPath: file.path,
|
|
targetPath: `${currentPath}/${copiedItems[index] || file.name}`,
|
|
targetName: copiedItems[index] || file.name,
|
|
}));
|
|
|
|
const undoAction: UndoAction = {
|
|
type: "copy",
|
|
description: t("fileManager.copiedItems", { count: successCount }),
|
|
data: {
|
|
operation: "copy",
|
|
copiedFiles,
|
|
targetDirectory: currentPath,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
setUndoHistory((prev) => [...prev.slice(-9), undoAction]);
|
|
} else if (operation === "cut") {
|
|
const movedFiles = files.slice(0, successCount).map((file) => {
|
|
const targetPath = currentPath.endsWith("/")
|
|
? `${currentPath}${file.name}`
|
|
: `${currentPath}/${file.name}`;
|
|
return {
|
|
originalPath: file.path,
|
|
targetPath: targetPath,
|
|
targetName: file.name,
|
|
};
|
|
});
|
|
|
|
const undoAction: UndoAction = {
|
|
type: "cut",
|
|
description: t("fileManager.movedItems", { count: successCount }),
|
|
data: {
|
|
operation: "cut",
|
|
copiedFiles: movedFiles,
|
|
targetDirectory: currentPath,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
setUndoHistory((prev) => [...prev.slice(-9), undoAction]);
|
|
}
|
|
}
|
|
|
|
if (successCount > 0) {
|
|
const operationText =
|
|
operation === "copy" ? t("fileManager.copy") : t("fileManager.move");
|
|
if (operation === "copy" && copiedItems.length > 0) {
|
|
const hasRenamed = copiedItems.some(
|
|
(name) => !files.some((file) => file.name === name),
|
|
);
|
|
|
|
if (hasRenamed) {
|
|
toast.success(
|
|
t("fileManager.operationCompletedSuccessfully", {
|
|
operation: operationText,
|
|
count: successCount,
|
|
}),
|
|
);
|
|
} else {
|
|
toast.success(
|
|
t("fileManager.operationCompleted", {
|
|
operation: operationText,
|
|
count: successCount,
|
|
}),
|
|
);
|
|
}
|
|
} else {
|
|
toast.success(
|
|
t("fileManager.operationCompleted", {
|
|
operation: operationText,
|
|
count: successCount,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
handleRefreshDirectory();
|
|
clearSelection();
|
|
|
|
if (operation === "cut") {
|
|
setClipboard(null);
|
|
}
|
|
} catch (error: unknown) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
toast.error(`${t("fileManager.pasteFailed")}: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
async function handleExtractArchive(file: FileItem) {
|
|
if (!sshSessionId) return;
|
|
|
|
try {
|
|
await ensureSSHConnection();
|
|
|
|
toast.info(t("fileManager.extractingArchive", { name: file.name }));
|
|
|
|
await extractSSHArchive(
|
|
sshSessionId,
|
|
file.path,
|
|
undefined,
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
|
|
toast.success(
|
|
t("fileManager.archiveExtractedSuccessfully", { name: file.name }),
|
|
);
|
|
|
|
handleRefreshDirectory();
|
|
} catch (error: unknown) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
toast.error(`${t("fileManager.extractFailed")}: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
function handleOpenCompressDialog(files: FileItem[]) {
|
|
setCompressDialogFiles(files);
|
|
}
|
|
|
|
async function handleCompress(archiveName: string, format: string) {
|
|
if (!sshSessionId || compressDialogFiles.length === 0) return;
|
|
|
|
try {
|
|
await ensureSSHConnection();
|
|
|
|
const paths = compressDialogFiles.map((f) => f.path);
|
|
const fileNames = compressDialogFiles.map((f) => f.name);
|
|
|
|
toast.info(
|
|
t("fileManager.compressingFiles", {
|
|
count: fileNames.length,
|
|
name: archiveName,
|
|
}),
|
|
);
|
|
|
|
await compressSSHFiles(
|
|
sshSessionId,
|
|
paths,
|
|
archiveName,
|
|
format,
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
|
|
toast.success(
|
|
t("fileManager.filesCompressedSuccessfully", {
|
|
name: archiveName,
|
|
}),
|
|
);
|
|
|
|
handleRefreshDirectory();
|
|
clearSelection();
|
|
} catch (error: unknown) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
toast.error(`${t("fileManager.compressFailed")}: ${errorMessage}`);
|
|
}
|
|
}
|
|
|
|
function handleOpenTransferDialog(files: FileItem[], move: boolean) {
|
|
setTransferFiles(files);
|
|
setTransferMove(move);
|
|
setTransferDialogOpen(true);
|
|
}
|
|
|
|
async function handleTransferConfirm(
|
|
destSessionId: string,
|
|
destHostId: number,
|
|
destPath: string,
|
|
destPathLabel: string,
|
|
methodPreference: TransferMethodPreference,
|
|
parallelSegmentCount: number,
|
|
) {
|
|
if (!sshSessionId || !currentHost?.id || transferFiles.length === 0) return;
|
|
|
|
const sourcePaths = transferFiles.map((f) => f.path);
|
|
|
|
try {
|
|
await ensureSSHConnection();
|
|
|
|
const { transferId } = await transferToHost(
|
|
sshSessionId,
|
|
sourcePaths,
|
|
destSessionId,
|
|
destPath,
|
|
transferMove,
|
|
methodPreference,
|
|
parallelSegmentCount,
|
|
);
|
|
|
|
const monitorHandle = beginTransferProgressMonitoring(transferId, t, {
|
|
formatTransferMetrics,
|
|
});
|
|
if (!monitorHandle) return;
|
|
|
|
const finalStatus = await monitorHandle.waitForCompletion;
|
|
|
|
if (
|
|
finalStatus.status !== "success" &&
|
|
finalStatus.status !== "partial"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
void addTransferRecent(
|
|
currentHost.id,
|
|
destHostId,
|
|
destPathLabel,
|
|
destPathLabel,
|
|
);
|
|
|
|
window.dispatchEvent(
|
|
new CustomEvent("file-manager:refresh", {
|
|
detail: { hostId: destHostId, path: destPathLabel },
|
|
}),
|
|
);
|
|
|
|
if (transferMove) {
|
|
handleRefreshDirectory();
|
|
clearSelection();
|
|
}
|
|
} catch (error: unknown) {
|
|
const err = error as { message?: string };
|
|
toast.error(
|
|
`${t("transfer.transferError")}: ${err.message || t("fileManager.unknownError")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function handleUndo() {
|
|
if (undoHistory.length === 0) {
|
|
toast.info(t("fileManager.noUndoableActions"));
|
|
return;
|
|
}
|
|
|
|
const lastAction = undoHistory[undoHistory.length - 1];
|
|
|
|
try {
|
|
await ensureSSHConnection();
|
|
|
|
switch (lastAction.type) {
|
|
case "copy":
|
|
if (lastAction.data.copiedFiles) {
|
|
let successCount = 0;
|
|
for (const copiedFile of lastAction.data.copiedFiles) {
|
|
try {
|
|
const isDirectory =
|
|
files.find((f) => f.path === copiedFile.targetPath)?.type ===
|
|
"directory";
|
|
await deleteSSHItem(
|
|
sshSessionId!,
|
|
copiedFile.targetPath,
|
|
isDirectory,
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
successCount++;
|
|
} catch (error: unknown) {
|
|
console.error(
|
|
`Failed to delete copied file ${copiedFile.targetName}:`,
|
|
error,
|
|
);
|
|
toast.error(
|
|
t("fileManager.deleteCopiedFileFailed", {
|
|
name: copiedFile.targetName,
|
|
error:
|
|
error instanceof Error ? error.message : String(error),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (successCount > 0) {
|
|
setUndoHistory((prev) => prev.slice(0, -1));
|
|
toast.success(
|
|
t("fileManager.undoCopySuccess", { count: successCount }),
|
|
);
|
|
} else {
|
|
toast.error(t("fileManager.undoCopyFailedDelete"));
|
|
return;
|
|
}
|
|
} else {
|
|
toast.error(t("fileManager.undoCopyFailedNoInfo"));
|
|
return;
|
|
}
|
|
break;
|
|
|
|
case "cut":
|
|
if (lastAction.data.copiedFiles) {
|
|
let successCount = 0;
|
|
for (const movedFile of lastAction.data.copiedFiles) {
|
|
try {
|
|
await moveSSHItem(
|
|
sshSessionId!,
|
|
movedFile.targetPath,
|
|
movedFile.originalPath,
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
successCount++;
|
|
} catch (error: unknown) {
|
|
console.error(
|
|
`Failed to move back file ${movedFile.targetName}:`,
|
|
error,
|
|
);
|
|
toast.error(
|
|
t("fileManager.moveBackFileFailed", {
|
|
name: movedFile.targetName,
|
|
error:
|
|
error instanceof Error ? error.message : String(error),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (successCount > 0) {
|
|
setUndoHistory((prev) => prev.slice(0, -1));
|
|
toast.success(
|
|
t("fileManager.undoMoveSuccess", { count: successCount }),
|
|
);
|
|
} else {
|
|
toast.error(t("fileManager.undoMoveFailedMove"));
|
|
return;
|
|
}
|
|
} else {
|
|
toast.error(t("fileManager.undoMoveFailedNoInfo"));
|
|
return;
|
|
}
|
|
break;
|
|
|
|
case "delete":
|
|
toast.info(t("fileManager.undoDeleteNotSupported"));
|
|
setUndoHistory((prev) => prev.slice(0, -1));
|
|
return;
|
|
|
|
default:
|
|
toast.error(t("fileManager.undoTypeNotSupported"));
|
|
return;
|
|
}
|
|
|
|
handleRefreshDirectory();
|
|
} catch (error: unknown) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
toast.error(`${t("fileManager.undoOperationFailed")}: ${errorMessage}`);
|
|
console.error("Undo failed:", error);
|
|
}
|
|
}
|
|
|
|
function handleRenameFile(file: FileItem) {
|
|
setEditingFile(file);
|
|
}
|
|
|
|
function handleOpenPermissionsDialog(file: FileItem) {
|
|
setPermissionsDialogFile(file);
|
|
}
|
|
|
|
async function handleSavePermissions(file: FileItem, permissions: string) {
|
|
if (!sshSessionId) {
|
|
toast.error(t("fileManager.noSSHConnection"));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await changeSSHPermissions(
|
|
sshSessionId,
|
|
file.path,
|
|
permissions,
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
|
|
toast.success(t("fileManager.permissionsChangedSuccessfully"));
|
|
await handleRefreshDirectory();
|
|
} catch (error: unknown) {
|
|
console.error("Failed to change permissions:", error);
|
|
toast.error(t("fileManager.failedToChangePermissions"));
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function ensureSSHConnection() {
|
|
if (!sshSessionId || !currentHost) return;
|
|
|
|
const status = await getSSHStatus(sshSessionId);
|
|
|
|
if (!status.connected) {
|
|
await connectSSH(sshSessionId, {
|
|
hostId: currentHost.id,
|
|
ip: currentHost.ip,
|
|
port: currentHost.port,
|
|
username: currentHost.username,
|
|
password: currentHost.password,
|
|
sshKey: currentHost.key,
|
|
keyPassword: currentHost.keyPassword,
|
|
authType: currentHost.authType,
|
|
credentialId: currentHost.credentialId,
|
|
userId: currentHost.userId,
|
|
jumpHosts: currentHost.jumpHosts,
|
|
useSocks5: currentHost.useSocks5,
|
|
socks5Host: currentHost.socks5Host,
|
|
socks5Port: currentHost.socks5Port,
|
|
socks5Username: currentHost.socks5Username,
|
|
socks5Password: currentHost.socks5Password,
|
|
socks5ProxyChain: currentHost.socks5ProxyChain,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function handleConfirmCreate(name: string) {
|
|
if (!createIntent || !sshSessionId) return;
|
|
|
|
try {
|
|
await ensureSSHConnection();
|
|
|
|
if (createIntent.type === "file") {
|
|
await createSSHFile(
|
|
sshSessionId,
|
|
currentPath,
|
|
name,
|
|
"",
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
toast.success(t("fileManager.fileCreatedSuccessfully", { name }));
|
|
} else {
|
|
await createSSHFolder(
|
|
sshSessionId,
|
|
currentPath,
|
|
name,
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
toast.success(t("fileManager.folderCreatedSuccessfully", { name }));
|
|
}
|
|
|
|
setCreateIntent(null);
|
|
handleRefreshDirectory();
|
|
} catch (error: unknown) {
|
|
const axiosError = error as {
|
|
response?: { status?: number; data?: { error?: string } };
|
|
};
|
|
if (
|
|
axiosError.response?.status === 403 ||
|
|
axiosError.response?.data?.error
|
|
?.toLowerCase()
|
|
.includes("permission denied")
|
|
) {
|
|
toast.error(t("fileManager.permissionDenied"));
|
|
} else {
|
|
toast.error(t("fileManager.failedToCreateItem"));
|
|
}
|
|
console.error("Create failed:", error);
|
|
}
|
|
}
|
|
|
|
function handleCancelCreate() {
|
|
setCreateIntent(null);
|
|
}
|
|
|
|
async function handleRenameConfirm(file: FileItem, newName: string) {
|
|
if (!sshSessionId) return;
|
|
|
|
try {
|
|
await ensureSSHConnection();
|
|
|
|
await renameSSHItem(
|
|
sshSessionId,
|
|
file.path,
|
|
newName,
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
|
|
toast.success(
|
|
t("fileManager.itemRenamedSuccessfully", { name: newName }),
|
|
);
|
|
setEditingFile(null);
|
|
handleRefreshDirectory();
|
|
} catch (error: unknown) {
|
|
const axiosError = error as {
|
|
response?: { status?: number; data?: { error?: string } };
|
|
};
|
|
if (
|
|
axiosError.response?.status === 403 ||
|
|
axiosError.response?.data?.error
|
|
?.toLowerCase()
|
|
.includes("permission denied")
|
|
) {
|
|
toast.error(t("fileManager.permissionDenied"));
|
|
} else {
|
|
toast.error(t("fileManager.failedToRenameItem"));
|
|
}
|
|
console.error("Rename failed:", error);
|
|
}
|
|
}
|
|
|
|
function handleStartEdit(file: FileItem) {
|
|
setEditingFile(file);
|
|
}
|
|
|
|
function handleCancelEdit() {
|
|
setEditingFile(null);
|
|
}
|
|
|
|
async function handleTotpSubmit(code: string) {
|
|
if (!totpSessionId || !code) return;
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
const result = await verifySSHTOTP(totpSessionId, code);
|
|
|
|
if (result?.status === "success") {
|
|
setTotpRequired(false);
|
|
setTotpPrompt("");
|
|
setSshSessionId(totpSessionId);
|
|
setTotpSessionId(null);
|
|
|
|
try {
|
|
const response = await listSSHFiles(totpSessionId, currentPath);
|
|
const files = Array.isArray(response)
|
|
? response
|
|
: response?.files || [];
|
|
setFiles(files);
|
|
clearSelection();
|
|
initialLoadDoneRef.current = true;
|
|
toast.success(t("fileManager.connectedSuccessfully"));
|
|
|
|
logFileManagerActivity();
|
|
} catch (dirError: unknown) {
|
|
console.error("Failed to load initial directory:", dirError);
|
|
}
|
|
}
|
|
} catch (error: unknown) {
|
|
console.error("TOTP verification failed:", error);
|
|
toast.error(t("fileManager.totpVerificationFailed"));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
function handleTotpCancel() {
|
|
setTotpRequired(false);
|
|
setTotpPrompt("");
|
|
setTotpSessionId(null);
|
|
if (onClose) onClose();
|
|
}
|
|
|
|
async function handleWarpgateContinue() {
|
|
if (!warpgateSessionId) return;
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
const result = await verifySSHWarpgate(warpgateSessionId);
|
|
|
|
if (result?.status === "success") {
|
|
setWarpgateRequired(false);
|
|
setWarpgateUrl("");
|
|
setWarpgateSecurityKey("");
|
|
setSshSessionId(warpgateSessionId);
|
|
setWarpgateSessionId(null);
|
|
|
|
try {
|
|
const response = await listSSHFiles(warpgateSessionId, currentPath);
|
|
const files = Array.isArray(response)
|
|
? response
|
|
: response?.files || [];
|
|
setFiles(files);
|
|
clearSelection();
|
|
initialLoadDoneRef.current = true;
|
|
toast.success(t("fileManager.connectedSuccessfully"));
|
|
|
|
logFileManagerActivity();
|
|
} catch (dirError: unknown) {
|
|
console.error("Failed to load initial directory:", dirError);
|
|
}
|
|
}
|
|
} catch (error: unknown) {
|
|
console.error("Warpgate verification failed:", error);
|
|
toast.error(t("fileManager.warpgateVerificationFailed"));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
function handleWarpgateCancel() {
|
|
setWarpgateRequired(false);
|
|
setWarpgateUrl("");
|
|
setWarpgateSecurityKey("");
|
|
setWarpgateSessionId(null);
|
|
if (onClose) onClose();
|
|
}
|
|
|
|
function handleWarpgateOpenUrl() {
|
|
if (warpgateUrl) {
|
|
window.open(warpgateUrl, "_blank", "noopener,noreferrer");
|
|
}
|
|
}
|
|
|
|
async function handleAuthDialogSubmit(credentials: {
|
|
password?: string;
|
|
sshKey?: string;
|
|
keyPassword?: string;
|
|
}) {
|
|
if (!currentHost) return;
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
setShowAuthDialog(false);
|
|
|
|
const sessionId = currentHost.id.toString();
|
|
|
|
const result = await connectSSH(sessionId, {
|
|
hostId: currentHost.id,
|
|
ip: currentHost.ip,
|
|
port: currentHost.port,
|
|
username: currentHost.username,
|
|
password: credentials.password,
|
|
sshKey: credentials.sshKey,
|
|
keyPassword: credentials.keyPassword,
|
|
authType: credentials.password ? "password" : "key",
|
|
credentialId: currentHost.credentialId,
|
|
userId: currentHost.userId,
|
|
jumpHosts: currentHost.jumpHosts,
|
|
useSocks5: currentHost.useSocks5,
|
|
socks5Host: currentHost.socks5Host,
|
|
socks5Port: currentHost.socks5Port,
|
|
socks5Username: currentHost.socks5Username,
|
|
socks5Password: currentHost.socks5Password,
|
|
socks5ProxyChain: currentHost.socks5ProxyChain,
|
|
});
|
|
|
|
if (result?.requires_warpgate) {
|
|
setWarpgateRequired(true);
|
|
setWarpgateSessionId(sessionId);
|
|
setWarpgateUrl(result.url || "");
|
|
setWarpgateSecurityKey(result.securityKey || "N/A");
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (result?.requires_totp) {
|
|
setTotpRequired(true);
|
|
setTotpSessionId(sessionId);
|
|
setTotpPrompt(result.prompt || t("fileManager.verificationCodePrompt"));
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (result?.status === "auth_required") {
|
|
setAuthDialogReason(result.reason || "auth_failed");
|
|
setShowAuthDialog(true);
|
|
setIsLoading(false);
|
|
toast.error(t("fileManager.authenticationFailed"));
|
|
return;
|
|
}
|
|
|
|
setSshSessionId(sessionId);
|
|
|
|
try {
|
|
const response = await listSSHFiles(sessionId, currentPath);
|
|
const files = Array.isArray(response)
|
|
? response
|
|
: response?.files || [];
|
|
setFiles(files);
|
|
clearSelection();
|
|
initialLoadDoneRef.current = true;
|
|
toast.success(t("fileManager.connectedSuccessfully"));
|
|
logFileManagerActivity();
|
|
} catch (dirError: unknown) {
|
|
console.error("Failed to load initial directory:", dirError);
|
|
}
|
|
} catch (error: unknown) {
|
|
console.error("SSH connection with credentials failed:", error);
|
|
setAuthDialogReason("auth_failed");
|
|
setShowAuthDialog(true);
|
|
toast.error(
|
|
t("fileManager.failedToConnect") +
|
|
": " +
|
|
(error instanceof Error ? error.message : String(error)),
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
function handleAuthDialogCancel() {
|
|
setShowAuthDialog(false);
|
|
if (onClose) onClose();
|
|
}
|
|
|
|
async function handlePassphraseSubmit(passphrase: string) {
|
|
if (!currentHost) return;
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
setShowPassphraseDialog(false);
|
|
|
|
const sessionId = currentHost.id.toString();
|
|
|
|
const result = await connectSSH(sessionId, {
|
|
hostId: currentHost.id,
|
|
ip: currentHost.ip,
|
|
port: currentHost.port,
|
|
username: currentHost.username,
|
|
sshKey: currentHost.key,
|
|
keyPassword: passphrase,
|
|
authType: "key",
|
|
credentialId: currentHost.credentialId,
|
|
userId: currentHost.userId,
|
|
jumpHosts: currentHost.jumpHosts,
|
|
useSocks5: currentHost.useSocks5,
|
|
socks5Host: currentHost.socks5Host,
|
|
socks5Port: currentHost.socks5Port,
|
|
socks5Username: currentHost.socks5Username,
|
|
socks5Password: currentHost.socks5Password,
|
|
socks5ProxyChain: currentHost.socks5ProxyChain,
|
|
});
|
|
|
|
if (result?.status === "passphrase_required") {
|
|
setShowPassphraseDialog(true);
|
|
setIsLoading(false);
|
|
toast.error(t("fileManager.incorrectPassphrase"));
|
|
return;
|
|
}
|
|
|
|
if (result?.requires_totp) {
|
|
setTotpRequired(true);
|
|
setTotpSessionId(sessionId);
|
|
setTotpPrompt(result.prompt || t("fileManager.verificationCodePrompt"));
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
if (result?.status === "auth_required") {
|
|
setAuthDialogReason(result.reason || "auth_failed");
|
|
setShowAuthDialog(true);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
setSshSessionId(sessionId);
|
|
|
|
try {
|
|
const response = await listSSHFiles(sessionId, currentPath);
|
|
const files = Array.isArray(response)
|
|
? response
|
|
: response?.files || [];
|
|
setFiles(files);
|
|
clearSelection();
|
|
initialLoadDoneRef.current = true;
|
|
toast.success(t("fileManager.connectedSuccessfully"));
|
|
logFileManagerActivity();
|
|
} catch (dirError: unknown) {
|
|
console.error("Failed to load initial directory:", dirError);
|
|
}
|
|
} catch (error: unknown) {
|
|
console.error("SSH connection with passphrase failed:", error);
|
|
setShowPassphraseDialog(true);
|
|
toast.error(t("fileManager.incorrectPassphrase"));
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
function handlePassphraseCancel() {
|
|
setShowPassphraseDialog(false);
|
|
if (onClose) onClose();
|
|
}
|
|
|
|
function generateUniqueName(
|
|
baseName: string,
|
|
type: "file" | "directory",
|
|
): string {
|
|
const existingNames = files.map((f) => f.name.toLowerCase());
|
|
let candidateName = baseName;
|
|
let counter = 1;
|
|
|
|
while (existingNames.includes(candidateName.toLowerCase())) {
|
|
if (type === "file" && baseName.includes(".")) {
|
|
const lastDotIndex = baseName.lastIndexOf(".");
|
|
const nameWithoutExt = baseName.substring(0, lastDotIndex);
|
|
const extension = baseName.substring(lastDotIndex);
|
|
candidateName = `${nameWithoutExt}${counter}${extension}`;
|
|
} else {
|
|
candidateName = `${baseName}${counter}`;
|
|
}
|
|
counter++;
|
|
}
|
|
|
|
return candidateName;
|
|
}
|
|
|
|
async function handleFileDrop(
|
|
draggedFiles: FileItem[],
|
|
targetFolder: FileItem,
|
|
) {
|
|
if (!sshSessionId || targetFolder.type !== "directory") return;
|
|
|
|
try {
|
|
await ensureSSHConnection();
|
|
|
|
let successCount = 0;
|
|
const movedItems: string[] = [];
|
|
|
|
for (const file of draggedFiles) {
|
|
try {
|
|
const targetPath = targetFolder.path.endsWith("/")
|
|
? `${targetFolder.path}${file.name}`
|
|
: `${targetFolder.path}/${file.name}`;
|
|
|
|
if (file.path !== targetPath) {
|
|
await moveSSHItem(
|
|
sshSessionId,
|
|
file.path,
|
|
targetPath,
|
|
currentHost?.id,
|
|
currentHost?.userId?.toString(),
|
|
);
|
|
movedItems.push(file.name);
|
|
successCount++;
|
|
}
|
|
} catch (error: unknown) {
|
|
console.error(`Failed to move file ${file.name}:`, error);
|
|
toast.error(
|
|
t("fileManager.moveFileFailed", { name: file.name }) +
|
|
": " +
|
|
(error instanceof Error ? error.message : String(error)),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (successCount > 0) {
|
|
const movedFiles = draggedFiles.slice(0, successCount).map((file) => {
|
|
const targetPath = targetFolder.path.endsWith("/")
|
|
? `${targetFolder.path}${file.name}`
|
|
: `${targetFolder.path}/${file.name}`;
|
|
return {
|
|
originalPath: file.path,
|
|
targetPath: targetPath,
|
|
targetName: file.name,
|
|
};
|
|
});
|
|
|
|
const undoAction: UndoAction = {
|
|
type: "cut",
|
|
description: t("fileManager.dragMovedItems", {
|
|
count: successCount,
|
|
target: targetFolder.name,
|
|
}),
|
|
data: {
|
|
operation: "cut",
|
|
copiedFiles: movedFiles,
|
|
targetDirectory: targetFolder.path,
|
|
},
|
|
timestamp: Date.now(),
|
|
};
|
|
setUndoHistory((prev) => [...prev.slice(-9), undoAction]);
|
|
|
|
toast.success(
|
|
t("fileManager.successfullyMovedItems", {
|
|
count: successCount,
|
|
target: targetFolder.name,
|
|
}),
|
|
);
|
|
handleRefreshDirectory();
|
|
clearSelection();
|
|
}
|
|
} catch (error: unknown) {
|
|
console.error("Drag move operation failed:", error);
|
|
toast.error(
|
|
t("fileManager.moveOperationFailed") +
|
|
": " +
|
|
(error instanceof Error ? error.message : String(error)),
|
|
);
|
|
}
|
|
}
|
|
|
|
function handleFileDiff(file1: FileItem, file2: FileItem) {
|
|
if (file1.type !== "file" || file2.type !== "file") {
|
|
toast.error(t("fileManager.canOnlyCompareFiles"));
|
|
return;
|
|
}
|
|
|
|
if (!sshSessionId) {
|
|
toast.error(t("fileManager.noSSHConnection"));
|
|
return;
|
|
}
|
|
|
|
const offsetX = 100;
|
|
const offsetY = 80;
|
|
|
|
const windowId = `diff-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
const createWindowComponent = (windowId: string) => (
|
|
<DiffWindow
|
|
windowId={windowId}
|
|
file1={file1}
|
|
file2={file2}
|
|
sshSessionId={sshSessionId}
|
|
sshHost={currentHost}
|
|
initialX={offsetX}
|
|
initialY={offsetY}
|
|
/>
|
|
);
|
|
|
|
openWindow({
|
|
id: windowId,
|
|
type: "diff",
|
|
title: t("fileManager.fileComparison", {
|
|
file1: file1.name,
|
|
file2: file2.name,
|
|
}),
|
|
isMaximized: false,
|
|
component: createWindowComponent,
|
|
zIndex: Date.now(),
|
|
});
|
|
|
|
toast.success(
|
|
t("fileManager.comparingFiles", { file1: file1.name, file2: file2.name }),
|
|
);
|
|
}
|
|
|
|
async function handleDragToDesktop(files: FileItem[]) {
|
|
if (!currentHost || !sshSessionId) {
|
|
toast.error(t("fileManager.noSSHConnection"));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (systemDrag.isFileSystemAPISupported) {
|
|
await systemDrag.handleDragToSystem(files, {
|
|
enableToast: true,
|
|
onError: (error) => {
|
|
console.error("System-level drag failed:", error);
|
|
},
|
|
});
|
|
} else {
|
|
if (files.length === 1) {
|
|
await dragToDesktop.dragFileToDesktop(files[0]);
|
|
} else if (files.length > 1) {
|
|
await dragToDesktop.dragFilesToDesktop(files);
|
|
}
|
|
}
|
|
} catch (error: unknown) {
|
|
console.error("Drag to desktop failed:", error);
|
|
toast.error(
|
|
t("fileManager.dragFailed") +
|
|
": " +
|
|
(error instanceof Error ? error.message : String(error)),
|
|
);
|
|
}
|
|
}
|
|
|
|
function handleOpenTerminal(path: string) {
|
|
if (!currentHost) {
|
|
toast.error(t("fileManager.noHostSelected"));
|
|
return;
|
|
}
|
|
|
|
const windowCount = Date.now() % 10;
|
|
const offsetX = 200 + windowCount * 40;
|
|
const offsetY = 150 + windowCount * 40;
|
|
|
|
const createTerminalComponent = (windowId: string) => (
|
|
<TerminalWindow
|
|
windowId={windowId}
|
|
hostConfig={currentHost}
|
|
initialPath={path}
|
|
initialX={offsetX}
|
|
initialY={offsetY}
|
|
onPromoteToTab={onOpenTerminalTab}
|
|
/>
|
|
);
|
|
|
|
openWindow({
|
|
title: t("fileManager.terminal", { host: currentHost.name, path }),
|
|
x: offsetX,
|
|
y: offsetY,
|
|
width: 800,
|
|
height: 500,
|
|
isMaximized: false,
|
|
isMinimized: false,
|
|
component: createTerminalComponent,
|
|
});
|
|
|
|
toast.success(
|
|
t("terminal.terminalWithPath", { host: currentHost.name, path }),
|
|
);
|
|
}
|
|
|
|
function handleRunExecutable(file: FileItem) {
|
|
if (!currentHost) {
|
|
toast.error(t("fileManager.noHostSelected"));
|
|
return;
|
|
}
|
|
|
|
if (file.type !== "file" || !file.executable) {
|
|
toast.error(t("fileManager.onlyRunExecutableFiles"));
|
|
return;
|
|
}
|
|
|
|
const fileDir = file.path.substring(0, file.path.lastIndexOf("/"));
|
|
const fileName = file.name;
|
|
const executeCmd = `./${fileName}`;
|
|
|
|
const windowCount = Date.now() % 10;
|
|
const offsetX = 250 + windowCount * 40;
|
|
const offsetY = 200 + windowCount * 40;
|
|
|
|
const createExecutionTerminal = (windowId: string) => (
|
|
<TerminalWindow
|
|
windowId={windowId}
|
|
hostConfig={currentHost}
|
|
initialPath={fileDir}
|
|
initialX={offsetX}
|
|
initialY={offsetY}
|
|
executeCommand={executeCmd}
|
|
onPromoteToTab={onOpenTerminalTab}
|
|
/>
|
|
);
|
|
|
|
openWindow({
|
|
title: t("fileManager.runningFile", { file: file.name }),
|
|
x: offsetX,
|
|
y: offsetY,
|
|
width: 800,
|
|
height: 500,
|
|
isMaximized: false,
|
|
isMinimized: false,
|
|
component: createExecutionTerminal,
|
|
});
|
|
|
|
toast.success(t("fileManager.runningFile", { file: file.name }));
|
|
}
|
|
|
|
async function loadPinnedFiles() {
|
|
if (!currentHost?.id) return;
|
|
|
|
try {
|
|
const pinnedData = await getPinnedFiles(currentHost.id);
|
|
const pinnedPaths = new Set(
|
|
pinnedData.map((item: Record<string, unknown>) => item.path),
|
|
);
|
|
setPinnedFiles(pinnedPaths);
|
|
} catch (error) {
|
|
console.error("Failed to load pinned files:", error);
|
|
}
|
|
}
|
|
|
|
async function handlePinFile(file: FileItem) {
|
|
if (!currentHost?.id) return;
|
|
|
|
try {
|
|
await addPinnedFile(currentHost.id, file.path, file.name);
|
|
setPinnedFiles((prev) => new Set([...prev, file.path]));
|
|
setSidebarRefreshTrigger((prev) => prev + 1);
|
|
toast.success(
|
|
t("fileManager.filePinnedSuccessfully", { name: file.name }),
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to pin file:", error);
|
|
toast.error(t("fileManager.pinFileFailed"));
|
|
}
|
|
}
|
|
|
|
async function handleUnpinFile(file: FileItem) {
|
|
if (!currentHost?.id) return;
|
|
|
|
try {
|
|
await removePinnedFile(currentHost.id, file.path);
|
|
setPinnedFiles((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(file.path);
|
|
return newSet;
|
|
});
|
|
setSidebarRefreshTrigger((prev) => prev + 1);
|
|
toast.success(
|
|
t("fileManager.fileUnpinnedSuccessfully", { name: file.name }),
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to unpin file:", error);
|
|
toast.error(t("fileManager.unpinFileFailed"));
|
|
}
|
|
}
|
|
|
|
async function handleAddShortcut(path: string) {
|
|
if (!currentHost?.id) return;
|
|
|
|
try {
|
|
const folderName = path.split("/").pop() || path;
|
|
await addFolderShortcut(currentHost.id, path, folderName);
|
|
setSidebarRefreshTrigger((prev) => prev + 1);
|
|
toast.success(
|
|
t("fileManager.shortcutAddedSuccessfully", { name: folderName }),
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to add shortcut:", error);
|
|
toast.error(t("fileManager.addShortcutFailed"));
|
|
}
|
|
}
|
|
|
|
function isPinnedFile(file: FileItem): boolean {
|
|
return pinnedFiles.has(file.path);
|
|
}
|
|
|
|
async function recordRecentFile(file: FileItem) {
|
|
if (!currentHost?.id || file.type === "directory") return;
|
|
|
|
try {
|
|
await addRecentFile(currentHost.id, file.path, file.name);
|
|
setSidebarRefreshTrigger((prev) => prev + 1);
|
|
} catch (error) {
|
|
console.error("Failed to record recent file:", error);
|
|
}
|
|
}
|
|
|
|
async function handleSidebarFileOpen(sidebarItem: SidebarItem) {
|
|
const file: FileItem = {
|
|
name: sidebarItem.name,
|
|
path: sidebarItem.path,
|
|
type: "file",
|
|
};
|
|
|
|
await handleFileOpen(file);
|
|
}
|
|
|
|
async function handleFileNotFound(file: FileItem) {
|
|
if (!currentHost) return;
|
|
|
|
try {
|
|
await removeRecentFile(currentHost.id, file.path);
|
|
|
|
await removePinnedFile(currentHost.id, file.path);
|
|
|
|
setSidebarRefreshTrigger((prev) => prev + 1);
|
|
} catch (error) {
|
|
console.error("Failed to cleanup missing file:", error);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
setCreateIntent(null);
|
|
}, [currentPath]);
|
|
|
|
useEffect(() => {
|
|
if (currentHost?.id) {
|
|
loadPinnedFiles();
|
|
getServerMetricsById(currentHost.id)
|
|
.then((metrics) => {
|
|
if (
|
|
metrics?.disk?.percent != null &&
|
|
metrics.disk.usedHuman &&
|
|
metrics.disk.totalHuman
|
|
) {
|
|
setDiskInfo({
|
|
usedHuman: metrics.disk.usedHuman,
|
|
totalHuman: metrics.disk.totalHuman,
|
|
percent: metrics.disk.percent,
|
|
});
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
}, [currentHost?.id]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("fileManagerViewMode", viewMode);
|
|
}, [viewMode]);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem("fileManagerSortBy", sortBy);
|
|
localStorage.setItem("fileManagerSortOrder", sortOrder);
|
|
}, [sortBy, sortOrder]);
|
|
|
|
const filteredFiles = useMemo(
|
|
() =>
|
|
files
|
|
.filter((file) =>
|
|
file.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
|
)
|
|
.sort((a, b) => {
|
|
if (a.type === "directory" && b.type !== "directory") return -1;
|
|
if (a.type !== "directory" && b.type === "directory") return 1;
|
|
|
|
let result = 0;
|
|
switch (sortBy) {
|
|
case "name":
|
|
result = a.name.localeCompare(b.name, undefined, {
|
|
numeric: true,
|
|
sensitivity: "base",
|
|
});
|
|
break;
|
|
case "modified":
|
|
result = (a.modifiedTimestamp || 0) - (b.modifiedTimestamp || 0);
|
|
break;
|
|
case "size":
|
|
result = (a.size || 0) - (b.size || 0);
|
|
break;
|
|
}
|
|
return sortOrder === "desc" ? -result : result;
|
|
}),
|
|
[files, searchQuery, sortBy, sortOrder],
|
|
);
|
|
|
|
if (!currentHost) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center">
|
|
<div className="text-center">
|
|
<p className="text-lg text-muted-foreground mb-4">
|
|
{t("fileManager.selectHostToStart")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if ((isLoading || isReconnecting) && !sshSessionId) {
|
|
return (
|
|
<div className="h-full w-full flex flex-col bg-background relative">
|
|
<div className="flex-1 overflow-hidden min-h-0 relative">
|
|
<SimpleLoader
|
|
visible={!isConnectionLogExpanded}
|
|
message={t("fileManager.connecting")}
|
|
/>
|
|
</div>
|
|
<ConnectionLog
|
|
isConnecting={isLoading || isReconnecting}
|
|
isConnected={false}
|
|
hasConnectionError={hasConnectionError}
|
|
position={hasConnectionError ? "top" : "bottom"}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full flex flex-col bg-background relative overflow-hidden isolate">
|
|
<div
|
|
className="h-full w-full flex flex-col min-h-0"
|
|
style={{
|
|
visibility: isConnectionLogExpanded ? "hidden" : "visible",
|
|
}}
|
|
>
|
|
<FileManagerToolbar
|
|
t={t}
|
|
currentPath={currentPath}
|
|
navIndex={navIndex}
|
|
navHistoryLength={navHistory.length}
|
|
isLoading={isLoading}
|
|
sshSessionId={sshSessionId}
|
|
selectedFiles={selectedFiles}
|
|
searchQuery={searchQuery}
|
|
setSearchQuery={setSearchQuery}
|
|
viewMode={viewMode}
|
|
setViewMode={setViewMode}
|
|
sortBy={sortBy}
|
|
setSortBy={setSortBy}
|
|
sortOrder={sortOrder}
|
|
setSortOrder={setSortOrder}
|
|
setMobileSidebarOpen={setMobileSidebarOpen}
|
|
goBack={goBack}
|
|
goForward={goForward}
|
|
goUp={goUp}
|
|
navigateTo={navigateTo}
|
|
handleRefreshDirectory={handleRefreshDirectory}
|
|
handleDeleteFiles={handleDeleteFiles}
|
|
handleCopyFiles={handleCopyFiles}
|
|
handleFilesDropped={handleFilesDropped}
|
|
handleCreateNewFolder={handleCreateNewFolder}
|
|
handleCreateNewFile={handleCreateNewFile}
|
|
/>
|
|
|
|
<div
|
|
className="flex-1 flex px-3 pb-3 pt-2 gap-3 min-h-0 relative"
|
|
{...dragHandlers}
|
|
>
|
|
{/* Mobile sidebar backdrop */}
|
|
{mobileSidebarOpen && (
|
|
<div
|
|
className="fixed inset-0 z-20 bg-black/40 md:hidden"
|
|
onClick={() => setMobileSidebarOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Sidebar — fixed overlay on mobile, static on desktop */}
|
|
<div
|
|
className={cn(
|
|
"w-56 flex-shrink-0 h-full flex flex-col",
|
|
"md:flex",
|
|
mobileSidebarOpen
|
|
? "fixed left-0 top-0 bottom-0 w-64 z-30 flex"
|
|
: "hidden md:flex",
|
|
)}
|
|
>
|
|
<div className="flex-1 flex flex-col overflow-hidden min-h-0 border border-border bg-card">
|
|
<FileManagerSidebar
|
|
currentHost={currentHost}
|
|
currentPath={currentPath}
|
|
onPathChange={navigateTo}
|
|
onLoadDirectory={loadDirectory}
|
|
onFileOpen={handleSidebarFileOpen}
|
|
onItemContextMenu={handleSidebarItemContextMenu}
|
|
sshSessionId={sshSessionId}
|
|
refreshTrigger={sidebarRefreshTrigger}
|
|
diskInfo={diskInfo ?? undefined}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 relative overflow-hidden min-h-0 flex flex-col border border-border bg-card">
|
|
<div className="flex-1 relative min-h-0 h-full">
|
|
<FileManagerGrid
|
|
files={filteredFiles}
|
|
selectedFiles={selectedFiles}
|
|
onFileOpen={handleFileOpen}
|
|
onSelectionChange={setSelection}
|
|
onRefresh={handleRefreshDirectory}
|
|
onUpload={handleFilesDropped}
|
|
sortBy={sortBy}
|
|
sortOrder={sortOrder}
|
|
onSortChange={(field) => {
|
|
if (field === sortBy) {
|
|
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
|
} else {
|
|
setSortBy(field);
|
|
setSortOrder("asc");
|
|
}
|
|
}}
|
|
onDownload={(files) =>
|
|
files
|
|
.filter((f) => f.type === "file")
|
|
.forEach(handleDownloadFile)
|
|
}
|
|
onContextMenu={handleContextMenu}
|
|
viewMode={viewMode}
|
|
onRename={handleRenameConfirm}
|
|
editingFile={editingFile}
|
|
onStartEdit={handleStartEdit}
|
|
onCancelEdit={handleCancelEdit}
|
|
onDelete={handleDeleteFiles}
|
|
onCopy={handleCopyFiles}
|
|
onCut={handleCutFiles}
|
|
onPaste={handlePasteFiles}
|
|
onUndo={handleUndo}
|
|
hasClipboard={!!clipboard}
|
|
onFileDrop={handleFileDrop}
|
|
onFileDiff={handleFileDiff}
|
|
onSystemDragStart={handleFileDragStart}
|
|
onSystemDragEnd={handleFileDragEnd}
|
|
createIntent={createIntent}
|
|
onConfirmCreate={handleConfirmCreate}
|
|
onCancelCreate={handleCancelCreate}
|
|
onNewFile={handleCreateNewFile}
|
|
onNewFolder={handleCreateNewFolder}
|
|
/>
|
|
|
|
<FileManagerContextMenu
|
|
x={contextMenu.x}
|
|
y={contextMenu.y}
|
|
files={contextMenu.files}
|
|
isVisible={contextMenu.isVisible}
|
|
onClose={() =>
|
|
setContextMenu((prev) => ({ ...prev, isVisible: false }))
|
|
}
|
|
onDownload={(files) =>
|
|
files
|
|
.filter((f) => f.type === "file")
|
|
.forEach(handleDownloadFile)
|
|
}
|
|
onPreview={handleFileOpen}
|
|
onRename={handleRenameFile}
|
|
onCopy={handleCopyFiles}
|
|
onCut={handleCutFiles}
|
|
onPaste={handlePasteFiles}
|
|
onDelete={handleDeleteFiles}
|
|
onUpload={() => {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.multiple = true;
|
|
input.onchange = (e) => {
|
|
const files = (e.target as HTMLInputElement).files;
|
|
if (files) handleFilesDropped(files);
|
|
};
|
|
input.click();
|
|
}}
|
|
onNewFolder={handleCreateNewFolder}
|
|
onNewFile={handleCreateNewFile}
|
|
onRefresh={handleRefreshDirectory}
|
|
hasClipboard={!!clipboard}
|
|
onDragToDesktop={() => handleDragToDesktop(contextMenu.files)}
|
|
onOpenTerminal={(path) => handleOpenTerminal(path)}
|
|
onRunExecutable={(file) => handleRunExecutable(file)}
|
|
onPinFile={handlePinFile}
|
|
onUnpinFile={handleUnpinFile}
|
|
onAddShortcut={handleAddShortcut}
|
|
isPinned={isPinnedFile}
|
|
currentPath={currentPath}
|
|
onProperties={handleOpenPermissionsDialog}
|
|
onExtractArchive={handleExtractArchive}
|
|
onCompress={handleOpenCompressDialog}
|
|
onCopyPath={handleCopyPath}
|
|
onCopyFolderLink={handleCopyFolderLink}
|
|
onTransferToHost={handleOpenTransferDialog}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{currentHost && (
|
|
<TransferToHostDialog
|
|
open={transferDialogOpen}
|
|
onOpenChange={setTransferDialogOpen}
|
|
files={transferFiles}
|
|
move={transferMove}
|
|
sourceHost={currentHost}
|
|
sourceSessionId={sshSessionId}
|
|
onConfirm={handleTransferConfirm}
|
|
/>
|
|
)}
|
|
|
|
{currentHost && (
|
|
<PassphraseDialog
|
|
isOpen={showPassphraseDialog}
|
|
onSubmit={handlePassphraseSubmit}
|
|
onCancel={handlePassphraseCancel}
|
|
hostInfo={{
|
|
ip: currentHost.ip,
|
|
port: currentHost.port,
|
|
username: currentHost.username,
|
|
name: currentHost.name,
|
|
}}
|
|
backgroundColor="var(--bg-canvas)"
|
|
/>
|
|
)}
|
|
|
|
<FileManagerDialogs
|
|
compressDialogFiles={compressDialogFiles}
|
|
setCompressDialogFiles={setCompressDialogFiles}
|
|
handleCompress={handleCompress}
|
|
totpRequired={totpRequired}
|
|
totpPrompt={totpPrompt}
|
|
handleTotpSubmit={handleTotpSubmit}
|
|
handleTotpCancel={handleTotpCancel}
|
|
warpgateRequired={warpgateRequired}
|
|
warpgateUrl={warpgateUrl}
|
|
warpgateSecurityKey={warpgateSecurityKey}
|
|
handleWarpgateContinue={handleWarpgateContinue}
|
|
handleWarpgateCancel={handleWarpgateCancel}
|
|
handleWarpgateOpenUrl={handleWarpgateOpenUrl}
|
|
currentHost={currentHost}
|
|
showAuthDialog={showAuthDialog}
|
|
authDialogReason={authDialogReason}
|
|
handleAuthDialogSubmit={handleAuthDialogSubmit}
|
|
handleAuthDialogCancel={handleAuthDialogCancel}
|
|
permissionsDialogFile={permissionsDialogFile}
|
|
setPermissionsDialogFile={setPermissionsDialogFile}
|
|
handleSavePermissions={handleSavePermissions}
|
|
sudoDialogOpen={sudoDialogOpen}
|
|
setSudoDialogOpen={setSudoDialogOpen}
|
|
setPendingSudoOperation={setPendingSudoOperation}
|
|
handleSudoPasswordSubmit={handleSudoPasswordSubmit}
|
|
/>
|
|
<ConnectionLog
|
|
isConnecting={isReconnecting || isLoading}
|
|
isConnected={!!sshSessionId}
|
|
hasConnectionError={hasConnectionError}
|
|
position={hasConnectionError ? "top" : "bottom"}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FileManagerInner({
|
|
initialHost,
|
|
initialFilePath,
|
|
initialPath,
|
|
onClose,
|
|
onOpenTerminalTab,
|
|
isVisible = true,
|
|
}: FileManagerProps) {
|
|
return (
|
|
<WindowManager>
|
|
<FileManagerContent
|
|
initialHost={initialHost}
|
|
initialFilePath={initialFilePath}
|
|
initialPath={initialPath}
|
|
onClose={onClose}
|
|
onOpenTerminalTab={onOpenTerminalTab}
|
|
isVisible={isVisible}
|
|
/>
|
|
</WindowManager>
|
|
);
|
|
}
|
|
|
|
export function FileManager(props: FileManagerProps) {
|
|
return (
|
|
<ConnectionLogProvider>
|
|
<FileManagerInner {...props} />
|
|
</ConnectionLogProvider>
|
|
);
|
|
}
|