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.
-
+
-
+
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}
+