diff --git a/.dockerignore b/.dockerignore index 16c38af..8ea4d32 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,8 @@ ENV/ .venv*/ .uv-cache*/ .task*/ +.test-run.tmp/ +.worktrees/ # Python cache __pycache__/ diff --git a/README.md b/README.md index 6c5dd95..df00204 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ WebSSH is a secure, self-hosted workspace for SSH terminals and SFTP file operations in the browser. It is built for homelabs, server administration, and teams that want browser-based access without handing connection data to a hosted control plane. It is multi-user from the ground up, with separate accounts and per-user saved connections, keys, and settings. Browser dependencies are vendored locally, and WebSSH does not include telemetry.

- WebSSH tour showing saved profiles, live terminals, split panes, broadcast input, commands, and SFTP tools + WebSSH tour showing Quick Connect, live terminals, split panes, commands, and the source-first SFTP workspace

## Features @@ -82,7 +82,7 @@ WebSSH is a secure, self-hosted workspace for SSH terminals and SFTP file operat - **Persistent Session Names** - Custom tab names are retained for persistent sessions across browsers - **Configurable Scrollback** - Set 50 to 10,000 terminal lines and navigate them with the custom scrollbar - **Copy/Paste** - `Ctrl+C` copies selected terminal text but still sends an interrupt when nothing is selected; `Ctrl+V` pastes (`Cmd+C` / `Cmd+V` on macOS) -- **Keyboard Shortcuts** - Ctrl+K command palette, Ctrl+F search, Ctrl+1–9 tab switching +- **Keyboard Shortcuts** - Ctrl+K command palette, Ctrl+F search, Ctrl+1-9 tab switching - **Terminal Search** - Regex or plain-text in-terminal search (Ctrl+F) - **Save Transcript** - Download the session output as a text file - **Recent Connections** - Quick reconnect from your connection history @@ -103,21 +103,23 @@ WebSSH is a secure, self-hosted workspace for SSH terminals and SFTP file operat ### File Manager (SFTP) - **Active-Session Split** - In the single-terminal layout, open a compact SFTP browser that automatically follows the active SSH session -- **Dual-Pane Browser** - Side-by-side file browsing -- **Drag & Drop** - Transfer files between local and remote +- **Source-First Workspace** - Open active SSH sessions, saved SSH hosts, or a new SFTP quick connection from one consistent source launcher +- **One or Two File Areas** - Use the full workspace for one source or switch to a side-by-side layout for remote-to-remote work +- **Independent Source Tabs** - Keep multiple remote locations open without duplicating a source from the right side on the left +- **Drag & Drop Uploads** - Upload browser files and folders directly into the selected remote directory - **Server-to-Server** - Direct transfer between SSH hosts -- **Batch Operations** - Multi-select for bulk actions +- **Batch Operations** - Select individual items or all visible items for bulk download, transfer, and delete actions - **Context Menu** - Right-click for quick actions - **File Preview** - Inline preview for images and code (syntax-highlighted), with log tail mode - **Folder Download as ZIP** - Download entire directories as a ZIP archive -- **Quick Connect** - Browse files over SFTP without opening a terminal session -- **Local Filesystem Source** - Use your browser's local files as a transfer source +- **Saved Hosts and Quick Connect** - Reuse saved profiles or browse over SFTP without opening a terminal session +- **SMB Integration Path** - The source launcher reserves a disabled, clearly labeled `Coming soon` action for creating an SMB share; SMB connections are not active yet - **Transfer Queue** - Progress tracking with conflict resolution (skip / overwrite / apply to all) - **Efficient Binary Transfer** - Raw binary streaming (~33% smaller than base64) - **Inline Editor** - Edit text files directly in the browser and save back over SFTP

- File Manager + Source-first SFTP File Manager with independent tabs and two remote file areas

diff --git a/assets/commandlibrary.png b/assets/commandlibrary.png index ae8ce55..fc9befa 100644 Binary files a/assets/commandlibrary.png and b/assets/commandlibrary.png differ diff --git a/assets/connection-options.png b/assets/connection-options.png index 6fc6bcb..7da69cf 100644 Binary files a/assets/connection-options.png and b/assets/connection-options.png differ diff --git a/assets/connection-panel.png b/assets/connection-panel.png index 45201c9..8041195 100644 Binary files a/assets/connection-panel.png and b/assets/connection-panel.png differ diff --git a/assets/file-editing.gif b/assets/file-editing.gif index fd3a01c..1288808 100644 Binary files a/assets/file-editing.gif and b/assets/file-editing.gif differ diff --git a/assets/filemanager.png b/assets/filemanager.png index 913eb34..3d23009 100644 Binary files a/assets/filemanager.png and b/assets/filemanager.png differ diff --git a/assets/keys.png b/assets/keys.png index 2d0071c..493dfa8 100644 Binary files a/assets/keys.png and b/assets/keys.png differ diff --git a/assets/mobile.png b/assets/mobile.png index c354aa6..e81b028 100644 Binary files a/assets/mobile.png and b/assets/mobile.png differ diff --git a/assets/multi.png b/assets/multi.png index 9823813..2f59635 100644 Binary files a/assets/multi.png and b/assets/multi.png differ diff --git a/assets/session-diagnostics.png b/assets/session-diagnostics.png index f3b81e0..9f2f8f9 100644 Binary files a/assets/session-diagnostics.png and b/assets/session-diagnostics.png differ diff --git a/assets/session-workspace.png b/assets/session-workspace.png index 52afc78..09a1864 100644 Binary files a/assets/session-workspace.png and b/assets/session-workspace.png differ diff --git a/assets/themes.png b/assets/themes.png index 2e6d707..38f25fd 100644 Binary files a/assets/themes.png and b/assets/themes.png differ diff --git a/assets/webssh-demo.gif b/assets/webssh-demo.gif index 68647e1..fd3fe81 100644 Binary files a/assets/webssh-demo.gif and b/assets/webssh-demo.gif differ diff --git a/static/css/session-workspace.css b/static/css/session-workspace.css index 791c15f..00b8e4c 100644 --- a/static/css/session-workspace.css +++ b/static/css/session-workspace.css @@ -119,6 +119,10 @@ .fm-embedded-mode #fmPaneTabs, .fm-embedded-mode #fmTransfer, .fm-embedded-mode #fmLeftPane > .fm-pane-header, +.fm-embedded-mode #fmLeftPane > .fm-source-identity, +.fm-embedded-mode #fmLeftPane > .fm-pane-toolbar, +.fm-embedded-mode #fmLeftPane > .fm-file-list-header, +.fm-embedded-mode .fm-transfer-rail, .fm-embedded-mode #fmMobileUpload { display: none !important; } @@ -140,17 +144,55 @@ } .fm-embedded-mode .fm-toolbar { - flex-wrap: wrap; + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 8px; padding: 8px; } .fm-embedded-mode .fm-toolbar-left, .fm-embedded-mode .fm-toolbar-right { - flex: 1 1 auto; + width: 100%; + min-width: 0; } .fm-embedded-mode .fm-toolbar-right { - justify-content: flex-end; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 4px; +} + +.fm-embedded-mode .fm-toolbar-right .btn { + min-width: 0; + justify-content: center; + padding-inline: 6px; +} + +.fm-embedded-mode .fm-toolbar-right .btn-text { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.fm-embedded-mode .fm-file-item { + grid-template-columns: 24px 22px minmax(0, 1fr) auto; + min-height: 42px; + padding: 6px 10px; +} + +.fm-embedded-mode .fm-file-modified, +.fm-embedded-mode .fm-file-permissions { + display: none; +} + +.fm-embedded-mode .fm-file-size { + white-space: nowrap; } .notepad-panel { diff --git a/static/css/sftp-file-manager.css b/static/css/sftp-file-manager.css index 0a28f51..e32b15d 100644 --- a/static/css/sftp-file-manager.css +++ b/static/css/sftp-file-manager.css @@ -330,11 +330,6 @@ margin-left: 8px; } -.fm-host-badge.browser-local { - background: color-mix(in srgb, var(--accent-primary) 15%, transparent); - color: var(--accent-primary); -} - .fm-host-badge.ssh { background: color-mix(in srgb, var(--success-color) 15%, transparent); color: var(--success-color); @@ -583,6 +578,10 @@ max-height: 44px; } +.fm-queue.collapsed .fm-queue-list { + display: none; +} + .fm-queue-header { display: flex; justify-content: space-between; @@ -591,6 +590,13 @@ background: var(--bg-tertiary); border-bottom: 1px solid var(--border-color); cursor: pointer; + width: 100%; + border-top: 0; + border-right: 0; + border-left: 0; + color: inherit; + font: inherit; + text-align: left; } .fm-queue-header:hover { @@ -1516,3 +1522,1107 @@ .fm-queue-list::-webkit-scrollbar-thumb:hover { background: var(--border-hover); } + +/* Full-screen source workspace. The embedded session SFTP view keeps the rules + in session-workspace.css and does not use this mode. */ +#sftpFileManager.fm-workspace-mode { + padding: 0; + align-items: stretch; + justify-content: stretch; + background: + radial-gradient(circle at 50% -20%, color-mix(in srgb, var(--accent-primary) 10%, transparent), transparent 38%), + var(--bg-primary); + backdrop-filter: none; +} + +#sftpFileManager.fm-workspace-mode .modal-content { + width: 100%; + max-width: none; + height: 100vh; + max-height: none; + margin: 0; + border: 0; + border-radius: 0; + background: var(--bg-primary); + box-shadow: none; +} + +#sftpFileManager.fm-workspace-mode .fm-workspace-header { + position: relative; + min-height: 70px; + padding: 12px 22px; + border-bottom: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-primary) 94%, #000); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16); +} + +.fm-workspace-header h2, +.fm-workspace-controls, +.fm-layout-switch { + display: flex; + align-items: center; +} + +.fm-workspace-header h2 { + gap: 11px; + margin: 0; + font-size: 17px; +} + +.fm-workspace-brand { + color: var(--text-muted); + font-size: 10px; + font-weight: 800; + line-height: 1; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.fm-workspace-title-icon { + display: grid; + width: 38px; + height: 38px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--accent-primary) 38%, var(--border-color)); + border-radius: 10px; + color: var(--accent-primary); + background: color-mix(in srgb, var(--accent-primary) 10%, var(--bg-secondary)); +} + +.fm-workspace-title-icon .material-icons { + font-size: 21px; +} + +.fm-workspace-title-copy { + display: flex; + flex-direction: column; + gap: 4px; + line-height: 1.05; +} + +.fm-workspace-controls { + position: absolute; + left: 50%; + gap: 12px; + margin: 0; + transform: translateX(-50%); +} + +.fm-layout-switch { + overflow: hidden; + border: 1px solid var(--border-color); + border-radius: 9px; + background: var(--bg-secondary); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text-primary) 4%, transparent); +} + +.fm-layout-btn, +.fm-workspace-close, +.fm-source-tab-add, +.fm-source-launcher-close, +.fm-transfer-direction { + border: 0; + background: transparent; + color: var(--text-secondary); + cursor: pointer; +} + +.fm-layout-btn { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 38px; + padding: 0 14px; + border-right: 1px solid var(--border-color); +} + +.fm-layout-btn:last-child { + border-right: 0; +} + +.fm-layout-btn.active { + color: var(--accent-on-text, #fff); + background: color-mix(in srgb, var(--accent-primary) 88%, #fff 12%); +} + +.fm-layout-btn .material-icons, +.fm-workspace-close .material-icons { + font-size: 19px; +} + +.fm-workspace-close { + display: grid; + width: 36px; + height: 36px; + place-items: center; + margin-left: auto; + border-radius: 9px; +} + +.fm-workspace-close:hover, +.fm-source-tab-add:hover, +.fm-source-launcher-close:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +#sftpFileManager.fm-workspace-mode .modal-body { + min-height: 0; + padding: 14px 16px 12px; + gap: 12px; +} + +#sftpFileManager.fm-workspace-mode .modal-body > .fm-toolbar, +#sftpFileManager.fm-workspace-mode .modal-body > .fm-pane-tabs, +#sftpFileManager.fm-workspace-mode .modal-body > .fm-mobile-upload { + display: none !important; +} + +#sftpFileManager.fm-workspace-mode .fm-panes { + grid-template-columns: minmax(0, 1fr) 72px minmax(0, 1fr); + gap: 0; +} + +#sftpFileManager.fm-workspace-single .fm-transfer-rail, +#sftpFileManager.fm-workspace-single #fmRightPane { + display: none; +} + +#sftpFileManager.fm-workspace-single .fm-panes { + grid-template-columns: minmax(0, 1fr); +} + +#sftpFileManager.fm-workspace-single.fm-single-right #fmLeftPane { + display: none; +} + +#sftpFileManager.fm-workspace-single.fm-single-right #fmRightPane { + display: flex; +} + +#sftpFileManager.fm-workspace-mode .fm-pane { + border: 1px solid var(--border-color); + border-radius: 12px; + background: var(--bg-secondary); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.16); + transition: border-color 160ms ease, box-shadow 160ms ease; +} + +#sftpFileManager.fm-workspace-mode .fm-pane.active { + border-color: color-mix(in srgb, var(--accent-primary) 62%, var(--border-color)); + box-shadow: + 0 14px 34px rgba(0, 0, 0, 0.2), + 0 0 0 1px color-mix(in srgb, var(--accent-primary) 12%, transparent); +} + +.fm-pane-header { + min-height: 38px; +} + +#sftpFileManager.fm-workspace-mode .fm-pane-header { + display: flex; + align-items: stretch; + padding: 0; + overflow-x: auto; + border-bottom: 1px solid var(--border-color); + min-height: 44px; + background: color-mix(in srgb, var(--bg-secondary) 94%, #000); +} + +.fm-legacy-source-select { + display: none !important; +} + +.fm-source-tabs { + display: flex; + flex: 1; + min-width: 0; + align-items: stretch; +} + +.fm-source-tab { + display: flex; + min-width: 168px; + max-width: 260px; + border-right: 1px solid var(--border-color); + color: var(--text-secondary); + background: transparent; +} + +.fm-source-tab.active { + color: var(--text-primary); + background: color-mix(in srgb, var(--accent-primary) 8%, var(--bg-tertiary)); + box-shadow: inset 0 -2px var(--accent-primary); +} + +.fm-source-tab-activate, +.fm-source-tab-close { + border: 0; + background: transparent; + color: inherit; + cursor: pointer; +} + +.fm-source-tab-activate { + display: flex; + flex: 1; + min-width: 0; + align-items: center; + gap: 7px; + padding: 0 5px 0 12px; +} + +.fm-source-tab-close { + display: grid; + width: 32px; + place-items: center; + opacity: 0.55; +} + +.fm-source-tab-close:hover { + opacity: 1; +} + +.fm-source-tab-close .material-icons { + font-size: 16px; +} + +.fm-source-tab-label { + overflow: hidden; + font-size: 13px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fm-source-tab-protocol, +.fm-protocol-badge { + padding: 2px 5px; + border: 1px solid color-mix(in srgb, var(--accent-primary) 65%, var(--border-color)); + border-radius: 3px; + color: var(--accent-primary); + font-size: 9px; + font-weight: 700; + line-height: 1.2; + letter-spacing: 0.06em; +} + +.fm-connection-dot { + width: 8px; + height: 8px; + flex: 0 0 8px; + border-radius: 50%; + background: var(--success-color); + box-shadow: 0 0 8px color-mix(in srgb, var(--success-color) 45%, transparent); +} + +.fm-source-tab-add { + display: grid; + width: 46px; + flex: 0 0 46px; + place-items: center; + border-left: 1px solid var(--border-color); +} + +.fm-source-identity { + display: flex; + min-height: 44px; + align-items: center; + gap: 10px; + padding: 7px 13px; + border-bottom: 1px solid var(--border-color); + color: var(--text-secondary); + font-size: 12px; +} + +.fm-pane-label { + flex: 0 0 auto; + padding: 4px 7px; + border: 1px solid var(--border-color); + border-radius: 5px; + color: var(--text-muted); + background: var(--bg-primary); + font-size: 9px; + font-weight: 750; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.fm-source-identity-empty { + color: var(--text-muted); + font-size: 11px; +} + +.fm-source-identity-name { + color: var(--text-primary); + font-weight: 650; +} + +.fm-source-identity-endpoint { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fm-source-identity-security { + display: inline-flex; + align-items: center; + gap: 5px; + margin-left: auto; + color: var(--success-color); + white-space: nowrap; +} + +.fm-source-identity-security .material-icons { + font-size: 15px; +} + +.fm-empty-source-button { + display: inline-flex; + align-items: center; + gap: 8px; + border: 0; + background: transparent; + color: var(--accent-primary); + cursor: pointer; +} + +#sftpFileManager.fm-workspace-mode .fm-pane-nav { + min-height: 46px; + padding: 7px 11px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-primary); +} + +#sftpFileManager.fm-workspace-mode .fm-path-input { + min-height: 30px; + border-color: var(--border-color); + border-radius: 5px; + background: color-mix(in srgb, var(--bg-primary) 88%, #000); + font-family: var(--font-mono, monospace); +} + +.fm-pane-toolbar { + display: flex; + min-height: 46px; + align-items: center; + gap: 2px; + padding: 6px 10px; + overflow-x: auto; + border-bottom: 1px solid var(--border-color); +} + +.fm-pane-toolbar button { + display: inline-flex; + min-height: 32px; + align-items: center; + gap: 6px; + padding: 0 9px; + border: 0; + border: 1px solid transparent; + border-radius: 6px; + background: transparent; + color: var(--text-secondary); + font-size: 12px; + white-space: nowrap; + cursor: pointer; +} + +.fm-pane-toolbar button:nth-child(2) { + margin-right: 7px; +} + +.fm-pane-toolbar button:nth-child(3) { + margin-left: 2px; +} + +.fm-pane-toolbar button:hover:not(:disabled) { + color: var(--text-primary); + border-color: var(--border-color); + background: var(--bg-hover); +} + +.fm-pane-toolbar button:disabled { + opacity: 0.46; + cursor: not-allowed; +} + +.fm-pane-toolbar button.is-danger:not(:disabled) { + color: var(--error-color); +} + +.fm-pane-toolbar .material-icons { + font-size: 17px; +} + +.fm-file-list-header, +#sftpFileManager.fm-workspace-mode .fm-file-item { + display: grid; + grid-template-columns: 26px 22px minmax(140px, 1fr) 90px 138px 112px; + align-items: center; + column-gap: 8px; +} + +.fm-file-list-header { + min-height: 38px; + padding: 0 12px; + border-bottom: 1px solid var(--border-color); + color: var(--text-muted); + font-size: 11px; + font-weight: 650; +} + +.fm-file-list-header input { + width: 14px; + height: 14px; + margin: 0; + accent-color: var(--accent-primary); +} + +.fm-file-list-header span:nth-of-type(1) { + grid-column: 3; +} + +#sftpFileManager.fm-workspace-mode .fm-file-list { + background: + linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 98%, #000), color-mix(in srgb, var(--bg-primary) 96%, #000)); +} + +#sftpFileManager.fm-workspace-mode .fm-file-item { + min-height: 42px; + padding: 0 12px; + border-bottom: 1px solid color-mix(in srgb, var(--border-color) 75%, transparent); + border-radius: 0; +} + +#sftpFileManager.fm-workspace-mode .fm-file-item:hover { + background: var(--bg-hover); +} + +#sftpFileManager.fm-workspace-mode .fm-file-item.selected { + background: color-mix(in srgb, var(--accent-primary) 18%, transparent); + box-shadow: inset 2px 0 var(--accent-primary); +} + +.fm-file-checkbox { + display: grid; + width: 24px; + height: 24px; + padding: 0; + place-items: center; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + font-size: 17px !important; +} + +.fm-file-checkbox:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 1px; +} + +.fm-file-item.selected .fm-file-checkbox { + color: var(--accent-primary); +} + +#sftpFileManager.fm-workspace-mode .fm-file-icon { + font-size: 18px; +} + +#sftpFileManager.fm-workspace-mode .fm-file-info { + min-width: 0; +} + +#sftpFileManager.fm-workspace-mode .fm-file-name { + overflow: hidden; + color: var(--text-primary); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fm-file-size, +.fm-file-modified, +.fm-file-permissions { + color: var(--text-secondary); + font-family: var(--font-mono, monospace); + font-size: 11px; +} + +.fm-transfer-rail { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 9px; + color: var(--text-muted); +} + +.fm-transfer-rail::before { + position: absolute; + top: 16px; + bottom: 16px; + left: 50%; + width: 1px; + background: linear-gradient(transparent, var(--border-color) 18%, var(--border-color) 82%, transparent); + content: ''; + transform: translateX(-50%); +} + +.fm-transfer-hint { + z-index: 1; + max-width: 66px; + padding: 5px 4px; + border-radius: 5px; + background: var(--bg-primary); + font-size: 9px; + font-weight: 650; + line-height: 1.3; + text-align: center; +} + +.fm-transfer-direction { + z-index: 1; + display: grid; + width: 40px; + height: 40px; + place-items: center; + border: 1px solid var(--border-color); + border-radius: 9px; + background: var(--bg-secondary); +} + +.fm-transfer-direction:not(:disabled):hover { + border-color: var(--accent-primary); + color: var(--accent-on-text, #fff); + background: var(--accent-primary); +} + +.fm-transfer-direction:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +#sftpFileManager.fm-workspace-mode .fm-pane-footer { + min-height: 38px; + padding: 0 12px; + border-top: 1px solid var(--border-color); + background: var(--bg-secondary); +} + +#sftpFileManager.fm-workspace-mode .fm-queue { + flex: 0 0 auto; + max-height: min(320px, 35vh); + border-radius: 10px; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12); +} + +#sftpFileManager.fm-workspace-mode .fm-queue.collapsed { + max-height: 44px; +} + +.fm-source-empty-state { + max-width: 360px; + margin: auto; + padding: 34px 26px; + border: 1px solid color-mix(in srgb, var(--border-color) 75%, transparent); + border-radius: 14px; + background: color-mix(in srgb, var(--bg-secondary) 82%, transparent); + text-align: center; +} + +.fm-empty-icon-shell { + display: grid; + width: 54px; + height: 54px; + place-items: center; + margin: 0 auto 14px; + border: 1px solid color-mix(in srgb, var(--accent-primary) 34%, var(--border-color)); + border-radius: 14px; + color: var(--accent-primary); + background: color-mix(in srgb, var(--accent-primary) 9%, var(--bg-tertiary)); +} + +.fm-source-empty-state .fm-empty-icon { + margin: 0; + color: inherit; + font-size: 28px; +} + +.fm-source-empty-state strong { + display: block; + color: var(--text-primary); + font-size: 14px; +} + +.fm-source-empty-state p { + max-width: 290px; + margin: 7px auto 17px; + color: var(--text-muted); + font-size: 11px; + line-height: 1.55; +} + +.fm-empty-source-cta { + min-height: 38px; + padding-inline: 16px; +} + +.fm-source-launcher { + position: absolute; + inset: 70px 0 0; + z-index: 40; + display: none; + align-items: center; + justify-content: center; + padding: 24px 4vw; + background: color-mix(in srgb, var(--bg-primary) 48%, transparent); + backdrop-filter: blur(5px); +} + +.fm-source-launcher.show { + display: flex; +} + +.fm-source-launcher-panel { + display: flex; + width: min(760px, 94vw); + max-height: calc(100vh - 140px); + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border-hover); + border-radius: 13px; + background: var(--bg-secondary); + box-shadow: 0 26px 70px rgba(0, 0, 0, 0.56); +} + +.fm-source-launcher-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 21px 24px 12px; +} + +.fm-source-launcher-header h3 { + margin: 0 0 4px; + font-size: 19px; +} + +.fm-source-launcher-header h3 span { + color: var(--accent-primary); +} + +.fm-source-launcher-header p { + margin: 0; + color: var(--text-muted); + font-size: 12px; +} + +.fm-source-launcher-close { + display: grid; + width: 32px; + height: 32px; + place-items: center; + border-radius: 5px; +} + +.fm-source-search { + display: flex; + align-items: center; + gap: 8px; + margin: 0 24px 13px; + padding: 0 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); +} + +.fm-source-search .material-icons { + color: var(--text-muted); + font-size: 19px; +} + +.fm-source-search input { + width: 100%; + min-height: 42px; + border: 0; + outline: 0; + background: transparent; + color: var(--text-primary); +} + +.fm-source-groups { + display: flex; + min-height: 0; + flex: 1; + flex-direction: column; + padding: 0 24px 14px; + overflow: hidden; +} + +.fm-source-primary-groups { + min-height: 0; + flex: 1; + padding-right: 4px; + overflow-y: auto; + scrollbar-width: thin; +} + +.fm-source-primary-groups:empty { + display: none; +} + +.fm-source-group h4 { + display: flex; + align-items: center; + justify-content: space-between; + margin: 12px 2px 6px; + color: var(--text-muted); + font-size: 10px; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +.fm-source-group-count { + display: inline-grid; + min-width: 20px; + height: 20px; + place-items: center; + border: 1px solid var(--border-color); + border-radius: 999px; + color: var(--text-secondary); + background: var(--bg-primary); + font-size: 9px; + letter-spacing: 0; +} + +#fm-source-group-saved + .fm-source-group-items { + max-height: none; + overflow: visible; +} + +.fm-source-row { + display: grid; + width: 100%; + min-height: 52px; + grid-template-columns: 28px minmax(160px, 1fr) 58px minmax(160px, 0.76fr); + align-items: center; + gap: 9px; + padding: 7px 12px; + border: 1px solid var(--border-color); + border-bottom: 0; + background: var(--bg-tertiary); + color: var(--text-primary); + text-align: left; + cursor: pointer; +} + +.fm-source-row:first-of-type { + border-radius: 8px 8px 0 0; +} + +.fm-source-row:last-child { + border-bottom: 1px solid var(--border-color); + border-radius: 0 0 8px 8px; +} + +.fm-source-row:hover:not(:disabled) { + position: relative; + z-index: 1; + border-color: color-mix(in srgb, var(--accent-primary) 45%, var(--border-hover)); + background: color-mix(in srgb, var(--accent-primary) 7%, var(--bg-hover)); +} + +.fm-layout-btn:focus-visible, +.fm-workspace-close:focus-visible, +.fm-source-tab-activate:focus-visible, +.fm-source-tab-close:focus-visible, +.fm-source-tab-add:focus-visible, +.fm-source-row:focus-visible, +.fm-transfer-direction:focus-visible, +.fm-empty-source-cta:focus-visible, +.fm-pane-tab:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; +} + +.fm-source-row.is-disabled { + opacity: 0.68; + cursor: not-allowed; +} + +.fm-source-row-icon { + color: var(--success-color); + font-size: 20px; +} + +.fm-source-row-main, +.fm-source-row-status { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.fm-source-row-main strong, +.fm-source-row-status strong { + overflow: hidden; + font-size: 12.5px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fm-source-row-main small, +.fm-source-row-status small { + display: inline-flex; + align-items: center; + gap: 4px; + overflow: hidden; + color: var(--text-muted); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fm-source-row-status strong { + color: var(--success-color); +} + +.fm-source-row-status .material-icons { + font-size: 13px; +} + +.fm-source-launcher-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + padding: 14px 24px 20px; + border-top: 1px solid var(--border-color); +} + +.fm-source-launcher-actions .btn { + min-height: 52px; + justify-content: center; + border-radius: 8px; +} + +.fm-source-launcher-actions .btn > span:last-child { + display: flex; + flex-direction: column; + align-items: flex-start; + line-height: 1.2; +} + +.fm-source-launcher-actions .btn small { + color: var(--text-muted); + font-size: 9px; +} + +.fm-source-no-results { + padding: 28px; + color: var(--text-muted); + text-align: center; +} + +.fm-source-no-sources { + display: flex; + min-height: 150px; + flex: 1; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 7px; + margin: 12px 4px 0; + padding: 28px; + border: 1px dashed color-mix(in srgb, var(--text-muted) 34%, var(--border-color)); + border-radius: 10px; + background: color-mix(in srgb, var(--bg-hover) 45%, transparent); + text-align: center; +} + +.fm-source-no-sources > .material-icons { + display: inline-grid; + width: 38px; + height: 38px; + place-items: center; + border: 1px solid color-mix(in srgb, var(--accent-primary) 25%, var(--border-color)); + border-radius: 10px; + background: color-mix(in srgb, var(--accent-primary) 9%, transparent); + color: var(--accent-primary); + font-size: 20px; +} + +.fm-source-no-sources strong { + color: var(--text-primary); + font-size: 14px; +} + +.fm-source-no-sources > span:last-child { + max-width: 380px; + color: var(--text-muted); + font-size: 11px; + line-height: 1.5; +} + +@media (max-width: 1050px) { + .fm-file-list-header, + #sftpFileManager.fm-workspace-mode .fm-file-item { + grid-template-columns: 26px 22px minmax(120px, 1fr) 82px 112px; + } + + .fm-file-list-header span:last-child, + .fm-file-permissions { + display: none; + } + + .fm-pane-toolbar button span:not(.material-icons) { + display: none; + } +} + +@media (max-width: 767px) { + #sftpFileManager.fm-workspace-mode .fm-workspace-header { + min-height: 62px; + padding: 9px 10px; + } + + .fm-workspace-header h2 .fm-workspace-brand, + .fm-layout-btn span:not(.material-icons) { + display: none; + } + + .fm-workspace-title-icon { + width: 34px; + height: 34px; + border-radius: 9px; + } + + .fm-workspace-title-copy { + font-size: 15px; + } + + .fm-workspace-controls { + position: static; + gap: 6px; + margin-left: auto; + transform: none; + } + + #sftpFileManager.fm-workspace-mode .fm-panes { + display: grid !important; + grid-template-columns: minmax(0, 1fr); + } + + #sftpFileManager.fm-workspace-mode.fm-workspace-split .modal-body > .fm-pane-tabs { + display: flex !important; + min-height: 42px; + flex: 0 0 42px; + gap: 4px; + padding: 4px; + border: 1px solid var(--border-color); + border-radius: 9px; + background: var(--bg-secondary); + } + + #sftpFileManager.fm-workspace-mode .fm-pane-tab { + display: inline-flex; + flex: 1; + align-items: center; + justify-content: center; + gap: 7px; + border: 0; + border-radius: 6px; + color: var(--text-secondary); + background: transparent; + font-size: 12px; + font-weight: 650; + } + + #sftpFileManager.fm-workspace-mode .fm-pane-tab.active { + color: var(--text-primary); + background: color-mix(in srgb, var(--accent-primary) 13%, var(--bg-tertiary)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent-primary) 28%, transparent); + } + + #sftpFileManager.fm-workspace-mode .fm-pane-tab .material-icons { + font-size: 16px; + } + + #sftpFileManager.fm-workspace-mode .fm-transfer-rail, + #sftpFileManager.fm-workspace-mode .fm-pane:not(.active) { + display: none !important; + } + + #sftpFileManager.fm-workspace-mode .fm-pane.active { + display: flex !important; + } + + .fm-source-launcher { + inset: 62px 0 0; + align-items: stretch; + padding: 8px; + } + + .fm-source-launcher-panel { + width: 100%; + max-height: 100%; + border-radius: 11px; + } + + .fm-source-row { + grid-template-columns: 22px minmax(100px, 1fr) 46px; + } + + .fm-source-row-status { + display: none; + } + + .fm-source-row.is-disabled .fm-source-row-status { + display: flex; + grid-column: 2 / 4; + align-items: flex-start; + } + + .fm-source-launcher-actions { + grid-template-columns: minmax(0, 1fr); + } + + .fm-source-empty-state { + width: 100%; + max-width: none; + padding: 10px; + border: 0; + background: transparent; + } + + .fm-source-empty-state .fm-empty-icon-shell { + display: none; + } + + .fm-source-empty-state strong, + .fm-source-empty-state p { + display: none; + } + + .fm-source-empty-state .fm-empty-source-cta { + min-height: 32px; + padding: 0 12px; + font-size: 11px; + } + + .fm-file-list-header, + #sftpFileManager.fm-workspace-mode .fm-file-item { + grid-template-columns: 24px 22px minmax(120px, 1fr) 74px; + } + + .fm-file-list-header span:nth-last-child(-n+2), + .fm-file-modified, + .fm-file-permissions { + display: none; + } +} diff --git a/static/css/style.css b/static/css/style.css index dd5e9be..a6299f4 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1728,6 +1728,12 @@ body.broadcast-active .terminal-wrapper:not(.unassigned) { .terminal-pane .xterm { flex: 1; + padding: 0; + background-color: var(--term-background); +} + +.terminal-pane .xterm-viewport { + background-color: var(--term-background); } .terminal-pane.active { diff --git a/static/js/app.js b/static/js/app.js index 4b9a6ba..5b61d6c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1004,6 +1004,12 @@ socket.on('keys_list', (data) => { ProfileManager.setKeys(data.keys); + const pendingKeyId = selectedConnectionProfileState?.pendingKeyId; + const keySelect = document.getElementById('keySelect'); + if (pendingKeyId && ProfileManager.keys.some(key => key.id === pendingKeyId)) { + keySelect.value = pendingKeyId; + selectedConnectionProfileState.pendingKeyId = null; + } }); socket.on('key_uploaded', (data) => { @@ -1144,15 +1150,19 @@ window.clearConnectionProfileState(); return null; } - ProfileManager.selectProfile(profileId); const requiredJumpHostId = profile.jump_host_id || ''; selectedConnectionProfileState = { profileId, + pendingKeyId: profile.auth_type === 'key' ? profile.key_id || null : null, missingJumpHost: Boolean( requiredJumpHostId && !window.JumpHostManager?.getById(requiredJumpHostId) ), }; + ProfileManager.selectProfile(profileId); + if (document.getElementById('keySelect')?.value === selectedConnectionProfileState.pendingKeyId) { + selectedConnectionProfileState.pendingKeyId = null; + } const profileContext = document.getElementById('connectionProfileContext'); profileContext?.classList.remove('hidden'); const profileContextName = document.getElementById('connectionProfileContextName'); diff --git a/static/js/browser-filesystem.js b/static/js/browser-filesystem.js deleted file mode 100644 index 340fbc6..0000000 --- a/static/js/browser-filesystem.js +++ /dev/null @@ -1,316 +0,0 @@ - -class BrowserFileSystem { - constructor() { - this.rootHandle = null; - this.currentHandle = null; - this.pathStack = []; - this.isSupported = this.checkSupport(); - } - - checkSupport() { - return 'showDirectoryPicker' in window; - } - - async requestAccess() { - if (!this.isSupported) { - throw new Error('File System Access API not supported in this browser'); - } - - try { - this.rootHandle = await window.showDirectoryPicker({ - mode: 'readwrite' - }); - this.currentHandle = this.rootHandle; - this.pathStack = [this.rootHandle.name]; - return true; - } catch (e) { - if (e.name === 'AbortError') { - return false; - } - throw e; - } - } - - hasAccess() { - return this.rootHandle !== null && this.currentHandle !== null; - } - - getCurrentPath() { - if (!this.hasAccess()) return ''; - return '/' + this.pathStack.join('/'); - } - - getRootName() { - return this.rootHandle ? this.rootHandle.name : ''; - } - - async listDirectory() { - if (!this.hasAccess()) { - throw new Error('No directory access. Call requestAccess() first.'); - } - - const files = []; - - try { - for await (const entry of this.currentHandle.values()) { - const fileInfo = { - name: entry.name, - is_dir: entry.kind === 'directory', - handle: entry, - permissions: 'rwx' - }; - - if (entry.kind === 'file') { - try { - const file = await entry.getFile(); - fileInfo.size = file.size; - fileInfo.modified = Math.floor(file.lastModified / 1000); - fileInfo.modified_str = new Date(file.lastModified).toLocaleString(); - } catch (e) { - fileInfo.size = 0; - fileInfo.modified = 0; - fileInfo.error = e.message; - } - } else { - fileInfo.size = 0; - fileInfo.modified = 0; - } - - files.push(fileInfo); - } - } catch (e) { - console.error('Error listing directory'); - throw e; - } - - files.sort((a, b) => { - if (a.is_dir && !b.is_dir) return -1; - if (!a.is_dir && b.is_dir) return 1; - return a.name.localeCompare(b.name); - }); - - return files; - } - - async navigateInto(dirName) { - if (!this.hasAccess()) { - throw new Error('No directory access'); - } - - try { - const subHandle = await this.currentHandle.getDirectoryHandle(dirName); - this.currentHandle = subHandle; - this.pathStack.push(dirName); - } catch (e) { - console.error('Error navigating into directory'); - throw e; - } - } - - async navigateUp() { - if (!this.hasAccess()) { - throw new Error('No directory access'); - } - - if (this.pathStack.length <= 1) { - return false; - } - - this.pathStack.pop(); - - this.currentHandle = this.rootHandle; - for (let i = 1; i < this.pathStack.length; i++) { - this.currentHandle = await this.currentHandle.getDirectoryHandle(this.pathStack[i]); - } - - return true; - } - - async navigateTo(path) { - if (!this.hasAccess()) { - throw new Error('No directory access'); - } - - this.currentHandle = this.rootHandle; - this.pathStack = [this.rootHandle.name]; - - if (!path || path === '/' || path === this.rootHandle.name) { - return; - } - - const parts = path.split('/').filter(p => p && p !== this.rootHandle.name); - for (const part of parts) { - await this.navigateInto(part); - } - } - - async readFile(fileHandle) { - const file = await fileHandle.getFile(); - return await file.arrayBuffer(); - } - - async readFileByName(fileName) { - if (!this.hasAccess()) { - throw new Error('No directory access'); - } - - const fileHandle = await this.currentHandle.getFileHandle(fileName); - return await this.readFile(fileHandle); - } - - async writeFile(fileName, data) { - if (!this.hasAccess()) { - throw new Error('No directory access'); - } - - try { - const fileHandle = await this.currentHandle.getFileHandle(fileName, { create: true }); - const writable = await fileHandle.createWritable(); - await writable.write(data); - await writable.close(); - } catch (e) { - console.error('Error writing file'); - throw e; - } - } - - async createWritableSink(fileName, directoryHandle = this.currentHandle) { - if (!directoryHandle) { - throw new Error('No directory access'); - } - const fileHandle = await directoryHandle.getFileHandle( - fileName, - { create: true }, - ); - return await fileHandle.createWritable(); - } - - async createDirectory(name) { - if (!this.hasAccess()) { - throw new Error('No directory access'); - } - - try { - return await this.currentHandle.getDirectoryHandle(name, { create: true }); - } catch (e) { - console.error('Error creating directory'); - throw e; - } - } - - async deleteEntry(name, recursive = true) { - if (!this.hasAccess()) { - throw new Error('No directory access'); - } - - try { - await this.currentHandle.removeEntry(name, { recursive }); - } catch (e) { - console.error('Error deleting entry'); - throw e; - } - } - - async rename(oldName, newName) { - if (!this.hasAccess()) { - throw new Error('No directory access'); - } - - try { - let isDir = false; - let handle; - - try { - handle = await this.currentHandle.getFileHandle(oldName); - } catch { - handle = await this.currentHandle.getDirectoryHandle(oldName); - isDir = true; - } - - if (isDir) { - await this._copyDirectoryRecursive(handle, this.currentHandle, newName); - } else { - const data = await this.readFile(handle); - await this.writeFile(newName, data); - } - - await this.deleteEntry(oldName); - } catch (e) { - console.error('Error renaming entry'); - throw e; - } - } - - async _copyDirectoryRecursive(sourceHandle, targetParentHandle, newName) { - const newDirHandle = await targetParentHandle.getDirectoryHandle(newName, { create: true }); - - for await (const entry of sourceHandle.values()) { - if (entry.kind === 'file') { - const file = await entry.getFile(); - const data = await file.arrayBuffer(); - const newFileHandle = await newDirHandle.getFileHandle(entry.name, { create: true }); - const writable = await newFileHandle.createWritable(); - await writable.write(data); - await writable.close(); - } else { - await this._copyDirectoryRecursive(entry, newDirHandle, entry.name); - } - } - } - - async getFileInfo(fileName) { - if (!this.hasAccess()) { - throw new Error('No directory access'); - } - - try { - const fileHandle = await this.currentHandle.getFileHandle(fileName); - const file = await fileHandle.getFile(); - return { - name: file.name, - size: file.size, - type: file.type, - modified: Math.floor(file.lastModified / 1000), - modified_str: new Date(file.lastModified).toLocaleString(), - is_dir: false, - handle: fileHandle - }; - } catch { - const dirHandle = await this.currentHandle.getDirectoryHandle(fileName); - return { - name: dirHandle.name, - size: 0, - type: 'directory', - modified: 0, - is_dir: true, - handle: dirHandle - }; - } - } - - async exists(name) { - if (!this.hasAccess()) return false; - - try { - await this.currentHandle.getFileHandle(name); - return true; - } catch { - try { - await this.currentHandle.getDirectoryHandle(name); - return true; - } catch { - return false; - } - } - } - - release() { - this.rootHandle = null; - this.currentHandle = null; - this.pathStack = []; - } -} - -if (typeof window !== 'undefined') { - window.BrowserFileSystem = BrowserFileSystem; -} diff --git a/static/js/file-workspace-state.js b/static/js/file-workspace-state.js new file mode 100644 index 0000000..451468f --- /dev/null +++ b/static/js/file-workspace-state.js @@ -0,0 +1,122 @@ +(function exposeFileWorkspaceState(globalObject) { + const PANES = new Set(['left', 'right']); + const LAYOUTS = new Set(['single', 'split']); + const SOURCE_FIELDS = [ + 'key', + 'type', + 'label', + 'endpoint', + 'protocol', + 'status', + 'security', + 'profileId', + 'sessionId', + 'connectionId', + ]; + + class FileWorkspaceState { + constructor(createPaneState) { + if (typeof createPaneState !== 'function') { + throw new TypeError('FileWorkspaceState requires a pane-state factory'); + } + this.createPaneState = createPaneState; + this.layout = 'single'; + this.activePane = 'left'; + this.tabSequence = 0; + this.panes = { + left: { tabs: [], activeTabId: null }, + right: { tabs: [], activeTabId: null }, + }; + } + + assertPane(pane) { + if (!PANES.has(pane)) { + throw new TypeError(`Unsupported workspace pane: ${pane}`); + } + } + + normalizeSource(source = {}) { + const normalized = {}; + SOURCE_FIELDS.forEach(field => { + normalized[field] = source[field] ?? null; + }); + return normalized; + } + + getTabs(pane) { + this.assertPane(pane); + return this.panes[pane].tabs; + } + + getActiveTab(pane) { + this.assertPane(pane); + const paneState = this.panes[pane]; + return paneState.tabs.find(tab => tab.id === paneState.activeTabId) || null; + } + + openTab(pane, source, paneState = this.createPaneState()) { + this.assertPane(pane); + const tab = { + id: `workspace-tab-${++this.tabSequence}`, + source: this.normalizeSource(source), + paneState, + }; + this.panes[pane].tabs.push(tab); + this.panes[pane].activeTabId = tab.id; + this.activePane = pane; + return tab; + } + + activateTab(pane, tabId) { + this.assertPane(pane); + const tab = this.panes[pane].tabs.find(item => item.id === tabId); + if (!tab) return null; + this.panes[pane].activeTabId = tab.id; + this.activePane = pane; + return tab; + } + + closeTab(pane, tabId) { + this.assertPane(pane); + const paneState = this.panes[pane]; + const index = paneState.tabs.findIndex(tab => tab.id === tabId); + if (index < 0) return { closed: null, active: this.getActiveTab(pane) }; + + const [closed] = paneState.tabs.splice(index, 1); + if (paneState.activeTabId === tabId) { + const neighbor = paneState.tabs[index] || paneState.tabs[index - 1] || null; + paneState.activeTabId = neighbor?.id || null; + } + return { closed, active: this.getActiveTab(pane) }; + } + + moveTab(fromPane, toPane, tabId) { + this.assertPane(fromPane); + this.assertPane(toPane); + if (fromPane === toPane) return this.activateTab(fromPane, tabId); + + const result = this.closeTab(fromPane, tabId); + if (!result.closed) return null; + this.panes[toPane].tabs.push(result.closed); + this.panes[toPane].activeTabId = result.closed.id; + this.activePane = toPane; + return result.closed; + } + + setLayout(layout) { + if (!LAYOUTS.has(layout)) { + throw new TypeError(`Unsupported workspace layout: ${layout}`); + } + this.layout = layout; + return layout; + } + + setActivePane(pane) { + this.assertPane(pane); + this.activePane = pane; + return pane; + } + } + + globalObject.FileWorkspaceState = FileWorkspaceState; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/static/js/i18n.js b/static/js/i18n.js index 2ba6b29..ef44d32 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -207,6 +207,40 @@ const translations = { 'fm.title': 'File Manager', 'fm.refresh': 'Refresh', + 'fm.preview': 'Preview', + 'fm.name': 'Name', + 'fm.size': 'Size', + 'fm.modified': 'Modified', + 'fm.permissions': 'Permissions', + 'fm.workspace.layout': 'File Manager layout', + 'fm.workspace.onePane': '1 pane', + 'fm.workspace.twoPanes': '2 panes', + 'fm.workspace.openSource': 'Open source', + 'fm.workspace.openSourceIn': 'Open source in', + 'fm.workspace.selectAll': 'Select all files', + 'fm.workspace.selectItem': 'Select', + 'fm.workspace.transferBetween': 'Transfer between panes', + 'fm.workspace.selectFiles': 'Select files', + 'fm.workspace.sourceHint': 'Choose an active SSH session or a saved host.', + 'fm.workspace.noAvailableSources': 'No sources available', + 'fm.workspace.noAvailableSourcesHint': 'Open an SSH session or create a new SFTP connection below.', + 'fm.workspace.searchSources': 'Search sources', + 'fm.workspace.newSftp': 'New SFTP connection', + 'fm.workspace.sftpOverSsh': 'SFTP over SSH', + 'fm.workspace.newSmb': 'New SMB share', + 'fm.workspace.connected': 'Connected', + 'fm.workspace.hostKeyTrusted': 'SSH host key trusted', + 'fm.workspace.available': 'Available', + 'fm.workspace.authenticationRequired': 'Authentication required', + 'fm.workspace.comingSoon': 'Coming soon', + 'fm.workspace.activeSessions': 'Active SSH sessions', + 'fm.workspace.savedHosts': 'Saved SSH hosts', + 'fm.workspace.quickConnections': 'SFTP quick connections', + 'fm.workspace.leftPane': 'Left side', + 'fm.workspace.rightPane': 'Right side', + 'fm.workspace.noSources': 'No matching sources', + 'fm.workspace.untitledSource': 'Source', + 'fm.workspace.closeSource': 'Close source', 'editor.edit': 'Edit', 'editor.save': 'Save', 'editor.cancel': 'Cancel', @@ -420,7 +454,6 @@ const translations = { 'fm.delete': 'Delete', 'fm.downloadOnlySSH': 'Download only works for SSH sources', 'fm.selectSource': '-- Select Source --', - 'fm.yourComputer': 'Your Computer', 'fm.sshSessions': 'SSH Sessions', 'fm.newConnection': '+ Quick Connect...', 'fm.goUp': 'Go up', @@ -488,7 +521,6 @@ const translations = { 'fm.saved': 'Saved', 'fm.failedToSave': 'Failed to save', 'fm.cannotTransferSameHost': 'Cannot transfer to same host. Use rename instead.', - 'fm.useFilesystemForLocal': 'Use your file system to add files to your local folder', 'fm.noTransfers': 'No transfers', 'fm.waiting': 'Waiting...', 'fm.done': 'Done', @@ -497,8 +529,6 @@ const translations = { 'fm.loadTimeout': 'Failed to load directory: timeout', 'fm.cannotNavigate': 'Cannot navigate to', 'fm.cannotOpen': 'Cannot open', - 'fm.errorReadingDir': 'Error reading directory', - 'fm.fsaNotSupported': 'File System Access API not supported. Use drag & drop instead.', 'fm.ctx.open': 'Open', 'fm.ctx.preview': 'Preview', 'fm.ctx.download': 'Download', @@ -842,6 +872,40 @@ const translations = { 'fm.title': 'Trình quản lý tệp', 'fm.refresh': 'Làm mới', + 'fm.preview': 'Xem trước', + 'fm.name': 'Tên', + 'fm.size': 'Kích thước', + 'fm.modified': 'Đã sửa đổi', + 'fm.permissions': 'Quyền', + 'fm.workspace.layout': 'Bố cục trình quản lý tệp', + 'fm.workspace.onePane': '1 khung', + 'fm.workspace.twoPanes': '2 khung', + 'fm.workspace.openSource': 'Mở nguồn', + 'fm.workspace.openSourceIn': 'Mở nguồn trong', + 'fm.workspace.selectAll': 'Chọn tất cả tệp', + 'fm.workspace.selectItem': 'Chọn', + 'fm.workspace.transferBetween': 'Chuyển giữa các khung', + 'fm.workspace.selectFiles': 'Chọn tệp', + 'fm.workspace.sourceHint': 'Chọn phiên SSH đang hoạt động hoặc máy chủ đã lưu.', + 'fm.workspace.noAvailableSources': 'Không có nguồn nào', + 'fm.workspace.noAvailableSourcesHint': 'Mở một phiên SSH hoặc tạo kết nối SFTP mới bên dưới.', + 'fm.workspace.searchSources': 'Tìm nguồn', + 'fm.workspace.newSftp': 'Kết nối SFTP mới', + 'fm.workspace.sftpOverSsh': 'SFTP qua SSH', + 'fm.workspace.newSmb': 'Chia sẻ SMB mới', + 'fm.workspace.connected': 'Đã kết nối', + 'fm.workspace.hostKeyTrusted': 'Khóa máy chủ SSH đáng tin cậy', + 'fm.workspace.available': 'Có sẵn', + 'fm.workspace.authenticationRequired': 'Cần xác thực', + 'fm.workspace.comingSoon': 'Sắp ra mắt', + 'fm.workspace.activeSessions': 'Phiên SSH đang hoạt động', + 'fm.workspace.savedHosts': 'Máy chủ SSH đã lưu', + 'fm.workspace.quickConnections': 'Kết nối nhanh SFTP', + 'fm.workspace.leftPane': 'Bên trái', + 'fm.workspace.rightPane': 'Bên phải', + 'fm.workspace.noSources': 'Không có nguồn phù hợp', + 'fm.workspace.untitledSource': 'Nguồn', + 'fm.workspace.closeSource': 'Đóng nguồn', 'editor.edit': 'Chỉnh sửa', 'editor.save': 'Lưu', 'editor.cancel': 'Hủy', @@ -1055,7 +1119,6 @@ const translations = { 'fm.delete': 'Xóa', 'fm.downloadOnlySSH': 'Tải xuống chỉ hoạt động với nguồn SSH', 'fm.selectSource': '-- Chọn nguồn --', - 'fm.yourComputer': 'Máy tính của bạn', 'fm.sshSessions': 'Các phiên SSH', 'fm.newConnection': '+ Kết nối nhanh...', 'fm.goUp': 'Lên trên', @@ -1123,7 +1186,6 @@ const translations = { 'fm.saved': 'Đã lưu', 'fm.failedToSave': 'Không thể lưu', 'fm.cannotTransferSameHost': 'Không thể truyền đến cùng một host. Hãy sử dụng đổi tên.', - 'fm.useFilesystemForLocal': 'Sử dụng hệ thống tệp của bạn để thêm tệp vào thư mục cục bộ', 'fm.noTransfers': 'Không có tiến trình truyền', 'fm.waiting': 'Đang chờ...', 'fm.done': 'Đã xong', @@ -1132,8 +1194,6 @@ const translations = { 'fm.loadTimeout': 'Không thể tải thư mục: hết thời gian chờ', 'fm.cannotNavigate': 'Không thể điều hướng đến', 'fm.cannotOpen': 'Không thể mở', - 'fm.errorReadingDir': 'Lỗi khi đọc thư mục', - 'fm.fsaNotSupported': 'File System Access API không được hỗ trợ. Hãy sử dụng kéo & thả.', 'fm.ctx.open': 'Mở', 'fm.ctx.preview': 'Xem trước', 'fm.ctx.download': 'Tải xuống', @@ -1476,6 +1536,40 @@ const translations = { 'fm.title': 'Dateimanager', 'fm.refresh': 'Aktualisieren', + 'fm.preview': 'Vorschau', + 'fm.name': 'Name', + 'fm.size': 'Größe', + 'fm.modified': 'Geändert', + 'fm.permissions': 'Berechtigungen', + 'fm.workspace.layout': 'Dateimanager-Layout', + 'fm.workspace.onePane': '1 Bereich', + 'fm.workspace.twoPanes': '2 Bereiche', + 'fm.workspace.openSource': 'Quelle öffnen', + 'fm.workspace.openSourceIn': 'Quelle öffnen in', + 'fm.workspace.selectAll': 'Alle Dateien auswählen', + 'fm.workspace.selectItem': 'Auswählen', + 'fm.workspace.transferBetween': 'Zwischen Bereichen übertragen', + 'fm.workspace.selectFiles': 'Dateien auswählen', + 'fm.workspace.sourceHint': 'Aktive SSH-Sitzung oder gespeicherten Host auswählen.', + 'fm.workspace.noAvailableSources': 'Keine Quellen verfügbar', + 'fm.workspace.noAvailableSourcesHint': 'Öffnen Sie eine SSH-Sitzung oder erstellen Sie unten eine neue SFTP-Verbindung.', + 'fm.workspace.searchSources': 'Quellen durchsuchen', + 'fm.workspace.newSftp': 'Neue SFTP-Verbindung', + 'fm.workspace.sftpOverSsh': 'SFTP über SSH', + 'fm.workspace.newSmb': 'Neue SMB-Freigabe', + 'fm.workspace.connected': 'Verbunden', + 'fm.workspace.hostKeyTrusted': 'SSH-Host-Key vertrauenswürdig', + 'fm.workspace.available': 'Verfügbar', + 'fm.workspace.authenticationRequired': 'Authentifizierung erforderlich', + 'fm.workspace.comingSoon': 'Demnächst', + 'fm.workspace.activeSessions': 'Aktive SSH-Sitzungen', + 'fm.workspace.savedHosts': 'Gespeicherte SSH-Hosts', + 'fm.workspace.quickConnections': 'SFTP-Schnellverbindungen', + 'fm.workspace.leftPane': 'Linke Seite', + 'fm.workspace.rightPane': 'Rechte Seite', + 'fm.workspace.noSources': 'Keine passenden Quellen', + 'fm.workspace.untitledSource': 'Quelle', + 'fm.workspace.closeSource': 'Quelle schließen', 'editor.edit': 'Bearbeiten', 'editor.save': 'Speichern', 'editor.cancel': 'Abbrechen', @@ -1706,7 +1800,6 @@ const translations = { 'fm.delete': 'Löschen', 'fm.downloadOnlySSH': 'Download funktioniert nur bei SSH-Quellen', 'fm.selectSource': '-- Quelle auswählen --', - 'fm.yourComputer': 'Ihr Computer', 'fm.sshSessions': 'SSH-Sitzungen', 'fm.newConnection': '+ Schnellverbindung...', 'fm.goUp': 'Nach oben', @@ -1774,7 +1867,6 @@ const translations = { 'fm.saved': 'Gespeichert', 'fm.failedToSave': 'Speichern fehlgeschlagen', 'fm.cannotTransferSameHost': 'Übertragung zum gleichen Host nicht möglich. Verwenden Sie stattdessen Umbenennen.', - 'fm.useFilesystemForLocal': 'Verwenden Sie Ihr Dateisystem, um Dateien zu Ihrem lokalen Ordner hinzuzufügen', 'fm.noTransfers': 'Keine Übertragungen', 'fm.waiting': 'Warten...', 'fm.done': 'Fertig', @@ -1783,8 +1875,6 @@ const translations = { 'fm.loadTimeout': 'Verzeichnis konnte nicht geladen werden: Zeitüberschreitung', 'fm.cannotNavigate': 'Navigation nicht möglich zu', 'fm.cannotOpen': 'Öffnen nicht möglich', - 'fm.errorReadingDir': 'Fehler beim Lesen des Verzeichnisses', - 'fm.fsaNotSupported': 'File System Access API nicht unterstützt. Verwenden Sie stattdessen Drag & Drop.', 'fm.ctx.open': 'Öffnen', 'fm.ctx.preview': 'Vorschau', 'fm.ctx.download': 'Herunterladen', @@ -2109,6 +2199,40 @@ const translations = { 'fm.title': 'Gestionnaire de fichiers', 'fm.refresh': 'Actualiser', + 'fm.preview': 'Aperçu', + 'fm.name': 'Nom', + 'fm.size': 'Taille', + 'fm.modified': 'Modifié', + 'fm.permissions': 'Permissions', + 'fm.workspace.layout': 'Disposition du gestionnaire de fichiers', + 'fm.workspace.onePane': '1 volet', + 'fm.workspace.twoPanes': '2 volets', + 'fm.workspace.openSource': 'Ouvrir une source', + 'fm.workspace.openSourceIn': 'Ouvrir une source dans', + 'fm.workspace.selectAll': 'Sélectionner tous les fichiers', + 'fm.workspace.selectItem': 'Sélectionner', + 'fm.workspace.transferBetween': 'Transférer entre les volets', + 'fm.workspace.selectFiles': 'Sélectionner des fichiers', + 'fm.workspace.sourceHint': 'Choisissez une session SSH active ou un hôte enregistré.', + 'fm.workspace.noAvailableSources': 'Aucune source disponible', + 'fm.workspace.noAvailableSourcesHint': 'Ouvrez une session SSH ou créez une nouvelle connexion SFTP ci-dessous.', + 'fm.workspace.searchSources': 'Rechercher des sources', + 'fm.workspace.newSftp': 'Nouvelle connexion SFTP', + 'fm.workspace.sftpOverSsh': 'SFTP via SSH', + 'fm.workspace.newSmb': 'Nouveau partage SMB', + 'fm.workspace.connected': 'Connecté', + 'fm.workspace.hostKeyTrusted': 'Clé hôte SSH approuvée', + 'fm.workspace.available': 'Disponible', + 'fm.workspace.authenticationRequired': 'Authentification requise', + 'fm.workspace.comingSoon': 'Bientôt disponible', + 'fm.workspace.activeSessions': 'Sessions SSH actives', + 'fm.workspace.savedHosts': 'Hôtes SSH enregistrés', + 'fm.workspace.quickConnections': 'Connexions rapides SFTP', + 'fm.workspace.leftPane': 'Côté gauche', + 'fm.workspace.rightPane': 'Côté droit', + 'fm.workspace.noSources': 'Aucune source correspondante', + 'fm.workspace.untitledSource': 'Source', + 'fm.workspace.closeSource': 'Fermer la source', 'editor.edit': 'Modifier', 'editor.save': 'Enregistrer', 'editor.cancel': 'Annuler', @@ -2348,7 +2472,6 @@ const translations = { 'fm.delete': 'Supprimer', 'fm.downloadOnlySSH': 'Le téléchargement ne fonctionne que pour les sources SSH', 'fm.selectSource': '-- Sélectionner une source --', - 'fm.yourComputer': 'Votre ordinateur', 'fm.sshSessions': 'Sessions SSH', 'fm.newConnection': '+ Connexion rapide...', 'fm.goUp': 'Remonter', @@ -2416,7 +2539,6 @@ const translations = { 'fm.saved': 'Enregistré', 'fm.failedToSave': 'Échec de l\'enregistrement de', 'fm.cannotTransferSameHost': 'Impossible de transférer vers le même hôte. Utilisez renommer à la place.', - 'fm.useFilesystemForLocal': 'Utilisez votre système de fichiers pour ajouter des fichiers à votre dossier local', 'fm.noTransfers': 'Aucun transfert', 'fm.waiting': 'En attente...', 'fm.done': 'Terminé', @@ -2425,8 +2547,6 @@ const translations = { 'fm.loadTimeout': 'Échec du chargement du répertoire: délai dépassé', 'fm.cannotNavigate': 'Impossible de naviguer vers', 'fm.cannotOpen': 'Impossible d\'ouvrir', - 'fm.errorReadingDir': 'Erreur de lecture du répertoire', - 'fm.fsaNotSupported': 'API File System Access non prise en charge. Utilisez le glisser-déposer à la place.', 'fm.ctx.open': 'Ouvrir', 'fm.ctx.preview': 'Aperçu', 'fm.ctx.download': 'Télécharger', @@ -2742,6 +2862,40 @@ const translations = { 'fm.title': 'Gestor de archivos', 'fm.refresh': 'Actualizar', + 'fm.preview': 'Vista previa', + 'fm.name': 'Nombre', + 'fm.size': 'Tamaño', + 'fm.modified': 'Modificado', + 'fm.permissions': 'Permisos', + 'fm.workspace.layout': 'Diseño del gestor de archivos', + 'fm.workspace.onePane': '1 panel', + 'fm.workspace.twoPanes': '2 paneles', + 'fm.workspace.openSource': 'Abrir origen', + 'fm.workspace.openSourceIn': 'Abrir origen en', + 'fm.workspace.selectAll': 'Seleccionar todos los archivos', + 'fm.workspace.selectItem': 'Seleccionar', + 'fm.workspace.transferBetween': 'Transferir entre paneles', + 'fm.workspace.selectFiles': 'Seleccionar archivos', + 'fm.workspace.sourceHint': 'Elige una sesión SSH activa o un host guardado.', + 'fm.workspace.noAvailableSources': 'No hay orígenes disponibles', + 'fm.workspace.noAvailableSourcesHint': 'Abre una sesión SSH o crea una nueva conexión SFTP abajo.', + 'fm.workspace.searchSources': 'Buscar orígenes', + 'fm.workspace.newSftp': 'Nueva conexión SFTP', + 'fm.workspace.sftpOverSsh': 'SFTP sobre SSH', + 'fm.workspace.newSmb': 'Nuevo recurso SMB', + 'fm.workspace.connected': 'Conectado', + 'fm.workspace.hostKeyTrusted': 'Clave de host SSH confiable', + 'fm.workspace.available': 'Disponible', + 'fm.workspace.authenticationRequired': 'Autenticación requerida', + 'fm.workspace.comingSoon': 'Próximamente', + 'fm.workspace.activeSessions': 'Sesiones SSH activas', + 'fm.workspace.savedHosts': 'Hosts SSH guardados', + 'fm.workspace.quickConnections': 'Conexiones rápidas SFTP', + 'fm.workspace.leftPane': 'Lado izquierdo', + 'fm.workspace.rightPane': 'Lado derecho', + 'fm.workspace.noSources': 'No hay orígenes coincidentes', + 'fm.workspace.untitledSource': 'Origen', + 'fm.workspace.closeSource': 'Cerrar origen', 'editor.edit': 'Editar', 'editor.save': 'Guardar', 'editor.cancel': 'Cancelar', @@ -2981,7 +3135,6 @@ const translations = { 'fm.delete': 'Eliminar', 'fm.downloadOnlySSH': 'La descarga solo funciona para fuentes SSH', 'fm.selectSource': '-- Seleccionar fuente --', - 'fm.yourComputer': 'Tu ordenador', 'fm.sshSessions': 'Sesiones SSH', 'fm.newConnection': '+ Conexión rápida...', 'fm.goUp': 'Subir', @@ -3049,7 +3202,6 @@ const translations = { 'fm.saved': 'Guardado', 'fm.failedToSave': 'Error al guardar', 'fm.cannotTransferSameHost': 'No se puede transferir al mismo host. Usa renombrar en su lugar.', - 'fm.useFilesystemForLocal': 'Usa tu sistema de archivos para agregar archivos a tu carpeta local', 'fm.noTransfers': 'Sin transferencias', 'fm.waiting': 'Esperando...', 'fm.done': 'Hecho', @@ -3058,8 +3210,6 @@ const translations = { 'fm.loadTimeout': 'Error al cargar el directorio: tiempo agotado', 'fm.cannotNavigate': 'No se puede navegar a', 'fm.cannotOpen': 'No se puede abrir', - 'fm.errorReadingDir': 'Error al leer el directorio', - 'fm.fsaNotSupported': 'API File System Access no soportada. Usa arrastrar y soltar en su lugar.', 'fm.ctx.open': 'Abrir', 'fm.ctx.preview': 'Vista previa', 'fm.ctx.download': 'Descargar', @@ -3375,6 +3525,40 @@ const translations = { 'fm.title': '文件管理器', 'fm.refresh': '刷新', + 'fm.preview': '预览', + 'fm.name': '名称', + 'fm.size': '大小', + 'fm.modified': '修改时间', + 'fm.permissions': '权限', + 'fm.workspace.layout': '文件管理器布局', + 'fm.workspace.onePane': '单面板', + 'fm.workspace.twoPanes': '双面板', + 'fm.workspace.openSource': '打开来源', + 'fm.workspace.openSourceIn': '打开来源于', + 'fm.workspace.selectAll': '选择所有文件', + 'fm.workspace.selectItem': '选择', + 'fm.workspace.transferBetween': '在面板之间传输', + 'fm.workspace.selectFiles': '选择文件', + 'fm.workspace.sourceHint': '选择活动 SSH 会话或已保存的主机。', + 'fm.workspace.noAvailableSources': '没有可用来源', + 'fm.workspace.noAvailableSourcesHint': '请打开一个 SSH 会话,或在下方新建 SFTP 连接。', + 'fm.workspace.searchSources': '搜索来源', + 'fm.workspace.newSftp': '新建 SFTP 连接', + 'fm.workspace.sftpOverSsh': '基于 SSH 的 SFTP', + 'fm.workspace.newSmb': '新建 SMB 共享', + 'fm.workspace.connected': '已连接', + 'fm.workspace.hostKeyTrusted': 'SSH 主机密钥已信任', + 'fm.workspace.available': '可用', + 'fm.workspace.authenticationRequired': '需要身份验证', + 'fm.workspace.comingSoon': '即将推出', + 'fm.workspace.activeSessions': '活动 SSH 会话', + 'fm.workspace.savedHosts': '已保存的 SSH 主机', + 'fm.workspace.quickConnections': 'SFTP 快速连接', + 'fm.workspace.leftPane': '左侧', + 'fm.workspace.rightPane': '右侧', + 'fm.workspace.noSources': '没有匹配的来源', + 'fm.workspace.untitledSource': '来源', + 'fm.workspace.closeSource': '关闭来源', 'editor.edit': '编辑', 'editor.save': '保存', 'editor.cancel': '取消', @@ -3605,7 +3789,6 @@ const translations = { 'fm.delete': '删除', 'fm.downloadOnlySSH': '下载功能仅适用于 SSH 源', 'fm.selectSource': '-- 选择来源 --', - 'fm.yourComputer': '你的电脑', 'fm.sshSessions': 'SSH 会话', 'fm.newConnection': '+ 快速连接...', 'fm.goUp': '返回上级', @@ -3673,7 +3856,6 @@ const translations = { 'fm.saved': '已保存', 'fm.failedToSave': '保存失败', 'fm.cannotTransferSameHost': '不能传输到同一主机,请改用重命名。', - 'fm.useFilesystemForLocal': '使用你的文件系统向本地目录添加文件', 'fm.noTransfers': '暂无传输任务', 'fm.waiting': '等待中...', 'fm.done': '完成', @@ -3682,8 +3864,6 @@ const translations = { 'fm.loadTimeout': '加载目录失败:超时', 'fm.cannotNavigate': '无法进入', 'fm.cannotOpen': '无法打开', - 'fm.errorReadingDir': '读取目录出错', - 'fm.fsaNotSupported': '当前浏览器不支持 File System Access API,请改用拖放上传。', 'fm.ctx.open': '打开', 'fm.ctx.preview': '预览', 'fm.ctx.download': '下载', diff --git a/static/js/sftp-file-manager.js b/static/js/sftp-file-manager.js index 90715c7..482c48f 100644 --- a/static/js/sftp-file-manager.js +++ b/static/js/sftp-file-manager.js @@ -7,13 +7,7 @@ class SFTPFileManager { this.displayMode = 'closed'; this.embeddedContainer = null; - this.browserFS = new BrowserFileSystem(); - - this.panes = { - left: this.createEmptyPaneState(), - right: this.createEmptyPaneState() - }; - this.activePane = 'left'; + this.initializeWorkspaceState(); this.availableSessions = []; this.quickConnections = []; @@ -25,6 +19,8 @@ class SFTPFileManager { this.uploadRefreshes = new Map(); this.s2sTerminalWaiters = new Map(); this.s2sEarlyTerminals = new Map(); + this.transferConnectionHolds = new Map(); + this.pendingQuickDisconnects = new Set(); this.conflictAction = null; this.applyToAll = false; @@ -59,6 +55,515 @@ class SFTPFileManager { }; } + initializeWorkspaceState() { + const WorkspaceState = window.FileWorkspaceState; + if (typeof WorkspaceState !== 'function') { + throw new Error('FileWorkspaceState must be loaded before SFTPFileManager'); + } + this.workspace = new WorkspaceState(() => this.createEmptyPaneState()); + this.workspaceEmptyPanes = { + left: this.createEmptyPaneState(), + right: this.createEmptyPaneState(), + }; + this.panes = this.workspaceEmptyPanes; + this.standalonePanes = this.panes; + this.embeddedPanes = null; + this.activePane = 'left'; + } + + syncPaneFromWorkspace(pane) { + const tab = this.workspace.getActiveTab(pane); + this.panes[pane] = tab?.paneState || this.workspaceEmptyPanes[pane]; + this.standalonePanes = this.panes; + return this.panes[pane]; + } + + enterEmbeddedPaneState() { + if (this.embeddedPanes) return this.embeddedPanes; + this.standalonePanes = this.panes; + this.embeddedPanes = { + left: this.createEmptyPaneState(), + right: this.createEmptyPaneState(), + }; + this.panes = this.embeddedPanes; + return this.embeddedPanes; + } + + restoreStandalonePaneState() { + if (!this.embeddedPanes) return this.panes; + this.panes = this.standalonePanes; + this.embeddedPanes = null; + ['left', 'right'].forEach(pane => this.syncPaneFromWorkspace(pane)); + return this.panes; + } + + setWorkspaceLayout(layout) { + const previousLayout = this.workspace.layout; + this.workspace.setLayout(layout); + if (layout === 'split' && previousLayout === 'single' && this.displayMode === 'modal') { + const sourcePane = this.workspace.activePane; + const targetPane = sourcePane === 'left' ? 'right' : 'left'; + const sourceTabs = this.workspace.getTabs(sourcePane); + if (!this.workspace.getActiveTab(targetPane) && sourceTabs.length > 1) { + const activeTab = this.workspace.getActiveTab(sourcePane); + this.workspace.moveTab(sourcePane, targetPane, activeTab.id); + this.syncPaneFromWorkspace(sourcePane); + this.syncPaneFromWorkspace(targetPane); + this.updatePathInput(sourcePane, this.panes[sourcePane].path || '/'); + this.updatePathInput(targetPane, this.panes[targetPane].path || '/'); + this.updatePaneBadge(sourcePane); + this.updatePaneBadge(targetPane); + this.renderPane(sourcePane); + this.renderPane(targetPane); + this.setActivePane(targetPane); + } else if (!this.workspace.getActiveTab(targetPane)) { + this.openSourceLauncher(targetPane); + } + } + this.renderWorkspaceChrome(); + return layout; + } + + buildSourceCatalog() { + const active = (this.availableSessions || []) + .filter(session => session.connected !== false) + .map(session => ({ + key: `ssh:${session.id}`, + type: 'ssh', + label: session.displayName || session.name || `${session.username}@${session.host}`, + endpoint: `${session.host}:${session.port || 22}`, + protocol: 'SFTP', + status: this.t('fm.workspace.connected', 'Connected'), + security: this.t('fm.workspace.hostKeyTrusted', 'SSH host key trusted'), + sessionId: session.id, + })); + const saved = (this.qcProfiles || []).map(profile => ({ + key: `profile:${profile.id}`, + type: 'profile', + label: profile.name || `${profile.username}@${profile.host}`, + endpoint: `${profile.username}@${profile.host}:${profile.port || 22}`, + protocol: 'SFTP', + status: this.t('fm.workspace.available', 'Available'), + security: this.t('fm.workspace.authenticationRequired', 'Authentication required'), + profileId: profile.id, + })); + const quick = (this.quickConnections || []).map(connection => ({ + key: `qc:${connection.connectionId}`, + type: 'ssh', + label: `${connection.username}@${connection.host}`, + endpoint: `${connection.host}:${connection.port || 22}`, + protocol: 'SFTP', + status: this.t('fm.workspace.connected', 'Connected'), + security: this.t('fm.workspace.hostKeyTrusted', 'SSH host key trusted'), + connectionId: connection.connectionId, + })); + return [ + { id: 'active', label: this.t('fm.workspace.activeSessions', 'Active SSH sessions'), items: active }, + { id: 'saved', label: this.t('fm.workspace.savedHosts', 'Saved SSH hosts'), items: saved }, + { id: 'quick', label: this.t('fm.workspace.quickConnections', 'SFTP quick connections'), items: quick }, + ]; + } + + async openWorkspaceSource(pane, source) { + if (!source || source.disabled) return null; + if (source.type === 'profile') { + this.pendingQuickConnectPane = pane; + this.openQuickConnect(source.profileId); + return null; + } + + const paneState = this.createEmptyPaneState(); + const tab = this.workspace.openTab(pane, source, paneState); + this.syncPaneFromWorkspace(pane); + this.workspace.setActivePane(pane); + this.setActivePane(pane); + this.closeSourceLauncher(); + this.renderWorkspaceChrome(); + await this.onSourceChange(pane, source.key); + this.renderWorkspaceChrome(); + return tab; + } + + loadWorkspaceProfiles() { + if (!this.socket || this.workspaceProfilesPending) return; + this.workspaceProfilesPending = true; + this.socket.emit('list_profiles'); + this.socket.once('profiles_list', data => { + this.workspaceProfilesPending = false; + this.qcProfiles = data?.profiles || []; + if (this.sourceLauncherPane) this.renderSourceLauncher(); + }); + } + + openSourceLauncher(pane = this.workspace.activePane) { + if (this.displayMode === 'embedded') return false; + this.sourceLauncherPane = pane; + this.sourceLauncherReturnFocus = document.activeElement; + this.loadWorkspaceProfiles(); + this.renderSourceLauncher(''); + + const launcher = document.getElementById('fmSourceLauncher'); + launcher.classList.add('show'); + launcher.setAttribute('aria-hidden', 'false'); + const single = this.workspace.layout === 'single'; + const actionLabel = document.getElementById('fmSourceLauncherAction'); + const paneLabel = document.getElementById('fmSourceLauncherPane'); + actionLabel.textContent = single + ? this.t('fm.workspace.openSource', 'Open source') + : this.t('fm.workspace.openSourceIn', 'Open source in'); + paneLabel.hidden = single; + paneLabel.textContent = single + ? '' + : pane === 'left' + ? this.t('fm.workspace.leftPane', 'Left side') + : this.t('fm.workspace.rightPane', 'Right side'); + const search = document.getElementById('fmSourceSearch'); + search.value = ''; + search.focus(); + return true; + } + + closeSourceLauncher() { + const launcher = document.getElementById('fmSourceLauncher'); + if (!launcher) return; + launcher.classList.remove('show'); + launcher.setAttribute('aria-hidden', 'true'); + this.sourceLauncherPane = null; + if (this.sourceLauncherReturnFocus?.isConnected) this.sourceLauncherReturnFocus.focus(); + this.sourceLauncherReturnFocus = null; + } + + trapSourceLauncherFocus(event) { + const launcher = document.getElementById('fmSourceLauncher'); + if (!launcher?.classList.contains('show')) return false; + const focusable = Array.from(launcher.querySelectorAll( + 'button:not([disabled]), input:not([disabled]), [href], [tabindex]:not([tabindex="-1"])', + )).filter(element => ( + element.getClientRects().length > 0 + && element.getAttribute('aria-hidden') !== 'true' + )); + if (focusable.length === 0) { + event.preventDefault(); + return true; + } + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + if (event.shiftKey && (active === first || !launcher.contains(active))) { + event.preventDefault(); + last.focus(); + return true; + } + if (!event.shiftKey && (active === last || !launcher.contains(active))) { + event.preventDefault(); + first.focus(); + return true; + } + return false; + } + + renderSourceLauncher(query = document.getElementById('fmSourceSearch')?.value || '') { + const container = document.getElementById('fmSourceGroups'); + if (!container) return; + const normalizedQuery = query.trim().toLocaleLowerCase(); + const groups = this.buildSourceCatalog(); + const hasAvailableSources = groups.some(group => group.items.length > 0); + this.sourceCatalogByKey = new Map(); + const renderedGroups = groups.map(group => { + const items = group.items.filter(source => { + this.sourceCatalogByKey.set(source.key, source); + if (!normalizedQuery) return true; + return [source.label, source.endpoint, source.protocol, source.status, source.security] + .some(value => String(value || '').toLocaleLowerCase().includes(normalizedQuery)); + }); + if (items.length === 0) return null; + const rows = items.map(source => { + const icon = source.type === 'profile' ? 'bookmark' : 'terminal'; + const disabled = source.disabled ? ' disabled aria-disabled="true"' : ''; + return ` + `; + }).join(''); + return { id: group.id, html: `

+

${this.escapeHtml(group.label)}${items.length}

+
${rows}
+
` }; + }).filter(Boolean); + container.innerHTML = renderedGroups.length > 0 + ? `
${renderedGroups.map(group => group.html).join('')}
` + : hasAvailableSources + ? `
${this.escapeHtml(this.t('fm.workspace.noSources', 'No matching sources'))}
` + : `
+ + ${this.escapeHtml(this.t('fm.workspace.noAvailableSources', 'No sources available'))} + ${this.escapeHtml(this.t('fm.workspace.noAvailableSourcesHint', 'Open an SSH session or create a new SFTP connection below.'))} +
`; + } + + renderWorkspaceChrome() { + if (!this.modal || this.displayMode === 'embedded') return; + const single = this.workspace.layout === 'single'; + this.modal.classList.toggle('fm-workspace-single', single); + this.modal.classList.toggle('fm-workspace-split', !single); + this.modal.classList.toggle('fm-single-right', single && this.workspace.activePane === 'right'); + const singleButton = document.getElementById('fmLayoutSingle'); + const splitButton = document.getElementById('fmLayoutSplit'); + singleButton?.classList.toggle('active', single); + splitButton?.classList.toggle('active', !single); + singleButton?.setAttribute('aria-pressed', String(single)); + splitButton?.setAttribute('aria-pressed', String(!single)); + ['left', 'right'].forEach(pane => { + this.renderSourceTabs(pane); + this.renderSourceIdentity(pane); + const sideLabel = pane === 'left' + ? this.t('fm.workspace.leftPane', 'Left side') + : this.t('fm.workspace.rightPane', 'Right side'); + const sourceButton = document.querySelector(`[data-source-target="${pane}"]`); + sourceButton?.setAttribute( + 'aria-label', + single + ? this.t('fm.workspace.openSource', 'Open source') + : `${this.t('fm.workspace.openSource', 'Open source')}: ${sideLabel}`, + ); + const activeTab = this.workspace.getActiveTab(pane); + const legacySelect = document.getElementById(`fm${this.capitalize(pane)}Source`); + if (legacySelect) legacySelect.value = activeTab?.source.key || ''; + }); + this.updateWorkspaceActions(); + } + + renderSourceTabs(pane) { + const container = document.getElementById(`fm${this.capitalize(pane)}Tabs`); + if (!container) return; + const activeTab = this.workspace.getActiveTab(pane); + container.innerHTML = this.workspace.getTabs(pane).map(tab => ` + `).join(''); + } + + renderSourceIdentity(pane) { + const container = document.getElementById(`fm${this.capitalize(pane)}Identity`); + if (!container) return; + const tab = this.workspace.getActiveTab(pane); + const paneLabel = pane === 'left' + ? this.t('fm.workspace.leftPane', 'Left side') + : this.t('fm.workspace.rightPane', 'Right side'); + const paneBadge = this.workspace.layout === 'split' + ? `${this.escapeHtml(paneLabel)}` + : ''; + if (!tab) { + container.innerHTML = ` + ${paneBadge} + ${this.escapeHtml(this.t('fm.selectSourceAbove', 'Select a source above'))}`; + return; + } + container.innerHTML = ` + ${paneBadge} + ${this.escapeHtml(tab.source.label || '')} + ${this.escapeHtml(tab.source.protocol || '')} + ${this.escapeHtml(tab.source.endpoint || '')} + ${this.escapeHtml(tab.source.security || '')}`; + } + + activateSourceTab(pane, tabId) { + const tab = this.workspace.activateTab(pane, tabId); + if (!tab) return null; + this.syncPaneFromWorkspace(pane); + this.setActivePane(pane); + this.updatePathInput(pane, tab.paneState.path || '/'); + this.updatePaneBadge(pane); + this.renderPane(pane); + this.renderWorkspaceChrome(); + return tab; + } + + closeSourceTab(pane, tabId) { + const result = this.workspace.closeTab(pane, tabId); + if (!result.closed) return null; + this.releaseQuickConnectionIfUnused(result.closed.source); + this.syncPaneFromWorkspace(pane); + this.updatePathInput(pane, this.panes[pane].path || '/'); + this.updatePaneBadge(pane); + this.renderPane(pane); + this.renderWorkspaceChrome(); + if (!result.active && this.workspace.layout === 'single') this.openSourceLauncher(pane); + return result.closed; + } + + releaseQuickConnectionIfUnused(source) { + const connectionId = source?.connectionId; + if (!connectionId) return false; + + const stillInUse = ['left', 'right'].some(pane => ( + this.workspace.getTabs(pane).some(tab => tab.source.connectionId === connectionId) + )); + if (stillInUse) return false; + + if (this.hasOutstandingTransferForConnection(connectionId)) { + if (!this.pendingQuickDisconnects) this.pendingQuickDisconnects = new Set(); + this.pendingQuickDisconnects.add(connectionId); + return false; + } + + this.pendingQuickDisconnects?.delete(connectionId); + this.quickConnections = (this.quickConnections || []).filter( + connection => connection.connectionId !== connectionId + ); + this.socket.emit('quick_disconnect', { connection_id: connectionId }); + this.updateSessionLists(); + return true; + } + + getTransferConnectionIds(transfer = {}) { + const ids = Array.isArray(transfer.connectionIds) + ? transfer.connectionIds + : [transfer.sessionId, transfer.sourceSessionId, transfer.targetSessionId]; + return [...new Set(ids.filter(Boolean))]; + } + + retainTransferConnections(connectionIds) { + if (!this.transferConnectionHolds) this.transferConnectionHolds = new Map(); + [...new Set((connectionIds || []).filter(Boolean))].forEach(connectionId => { + const current = this.transferConnectionHolds.get(connectionId) || 0; + this.transferConnectionHolds.set(connectionId, current + 1); + }); + } + + releaseTransferConnections(connectionIds) { + if (!this.transferConnectionHolds) return; + [...new Set((connectionIds || []).filter(Boolean))].forEach(connectionId => { + const remaining = (this.transferConnectionHolds.get(connectionId) || 0) - 1; + if (remaining > 0) this.transferConnectionHolds.set(connectionId, remaining); + else this.transferConnectionHolds.delete(connectionId); + }); + } + + hasOutstandingTransferForConnection(connectionId) { + if ((this.transferConnectionHolds?.get(connectionId) || 0) > 0) return true; + return (this.transferQueue || []).some(transfer => ( + ['pending', 'active'].includes(transfer.status) + && this.getTransferConnectionIds(transfer).includes(connectionId) + )); + } + + flushPendingQuickDisconnects() { + Array.from(this.pendingQuickDisconnects || []).forEach(connectionId => { + this.releaseQuickConnectionIfUnused({ connectionId: connectionId }); + }); + } + + previewSelected() { + const state = this.panes[this.activePane]; + if (!state || state.selected.size !== 1) return false; + const index = Array.from(state.selected)[0]; + const file = state.files[index]; + if (!file || file.is_dir) return false; + this.handleItemDblClick(this.activePane, index); + return true; + } + + canTransferBetweenPanes(sourcePane, targetPane) { + if (!this.workspace || this.workspace.layout !== 'split') return false; + if (!this.workspace.getActiveTab(sourcePane) || !this.workspace.getActiveTab(targetPane)) return false; + + const source = this.panes[sourcePane]; + const target = this.panes[targetPane]; + const sourceId = source?.sessionId || source?.connectionId; + const targetId = target?.sessionId || target?.connectionId; + return source?.type === 'ssh' + && target?.type === 'ssh' + && !source.loading + && !target.loading + && !source.autoHomeEligible + && !target.autoHomeEligible + && !source.error + && !target.error + && Boolean(sourceId) + && Boolean(targetId) + && sourceId !== targetId + && typeof source.path === 'string' + && typeof target.path === 'string'; + } + + updateWorkspaceActions() { + if (this.displayMode !== 'modal' || !this.workspace) return; + const state = this.panes[this.activePane]; + const selectedCount = state?.selected?.size || 0; + const selectedFile = selectedCount === 1 ? state.files[Array.from(state.selected)[0]] : null; + const hasSource = Boolean(state?.type); + const targetPane = this.activePane === 'left' ? 'right' : 'left'; + const transferAvailable = this.canTransferBetweenPanes(this.activePane, targetPane) && selectedCount > 0; + const setDisabled = (id, disabled) => { + const element = document.getElementById(id); + if (element) element.disabled = disabled; + }; + setDisabled('fmNewFolder', !hasSource); + setDisabled('fmEmbeddedUpload', state?.type !== 'ssh'); + setDisabled('fmDownload', !hasSource || selectedCount === 0); + setDisabled('fmPreview', !selectedFile || selectedFile.is_dir); + setDisabled('fmRename', selectedCount !== 1); + setDisabled('fmDelete', selectedCount === 0); + setDisabled('fmTransfer', !transferAvailable); + setDisabled('fmTransferRight', !(this.canTransferBetweenPanes('left', 'right') && this.panes.left.selected.size > 0)); + setDisabled('fmTransferLeft', !(this.canTransferBetweenPanes('right', 'left') && this.panes.right.selected.size > 0)); + ['left', 'right'].forEach(pane => { + const paneState = this.panes[pane]; + const paneSelection = paneState?.selected?.size || 0; + const onlyFile = paneSelection === 1 + ? paneState.files[Array.from(paneState.selected)[0]] + : null; + document.querySelectorAll(`[data-pane-toolbar="${pane}"] [data-pane-action]`).forEach(button => { + const action = button.dataset.paneAction; + const disabled = { + newfolder: !paneState?.type, + upload: paneState?.type !== 'ssh', + download: !paneState?.type || paneSelection === 0, + preview: !onlyFile || onlyFile.is_dir, + rename: paneSelection !== 1, + delete: paneSelection === 0, + }[action]; + button.disabled = Boolean(disabled); + }); + const selectAll = document.querySelector(`[data-pane-select-all="${pane}"]`); + if (selectAll) { + selectAll.disabled = !paneState?.type || paneState.files.length === 0; + selectAll.checked = paneState.files.length > 0 && paneSelection === paneState.files.length; + selectAll.indeterminate = paneSelection > 0 && paneSelection < paneState.files.length; + } + }); + const hint = document.getElementById('fmTransferHint'); + if (hint) { + const transferSelection = ( + this.canTransferBetweenPanes('left', 'right') + || this.canTransferBetweenPanes('right', 'left') + ) + ? Math.max(this.panes.left.selected.size, this.panes.right.selected.size) + : 0; + hint.textContent = transferSelection > 0 + ? `${transferSelection} ${this.t('fm.selected', 'selected')}` + : this.t('fm.workspace.selectFiles', 'Select files'); + } + } + init() { this.createModal(); this.setupSocketListeners(); @@ -75,9 +580,29 @@ class SFTPFileManager { modal.setAttribute('aria-hidden', 'true'); modal.innerHTML = `