feat: enhance sidebar edit functionality and prevent text selection
- Added aria-label and type attributes to the edit button in the sidebar. - Implemented a mechanism to prevent multiple edit triggers using requestAnimationFrame. - Updated mousedown event handling to allow real controls while preventing text selection in WKWebView. feat: add reconnectSession method to TabManager - Introduced reconnectSession method to facilitate session reconnections by ID. feat: implement disconnected overlay in terminal - Added setDisconnectedOverlay method to show/hide a disconnected overlay for terminal sessions. - Created _ensureDisconnectOverlay method to manage the overlay's existence and content. feat: extend Service API with file-related functions - Added FileExists and FileIsPrivateKey functions to check file existence and identify private keys. - Introduced ListSSHKeys function to retrieve SSH private key paths from the user's .ssh directory. - Implemented SFTPStat and SFTPUploadData functions for enhanced SFTP operations. refactor: clean up code formatting and improve readability - Adjusted code formatting for better readability across various files, including consistent use of line breaks and indentation.
This commit is contained in:
@@ -20,7 +20,7 @@ dev: deps
|
|||||||
|
|
||||||
# Production build
|
# Production build
|
||||||
build: deps
|
build: deps
|
||||||
$(WAILS) build
|
$(WAILS) build -ldflags "-s -w"
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
test:
|
test:
|
||||||
|
|||||||
@@ -31,20 +31,20 @@ Prefer a one-off session? Use **Cmd+Shift+Q** / **Ctrl+Shift+Q** to quick connec
|
|||||||
|
|
||||||
All shortcuts use **Cmd** on macOS and **Ctrl** on Windows/Linux.
|
All shortcuts use **Cmd** on macOS and **Ctrl** on Windows/Linux.
|
||||||
|
|
||||||
| Shortcut | Action |
|
| macOS Shortcut | Windows/Linux Shortcut | Action |
|
||||||
| ----------------- | --------------------------------- |
|
| ------------- | ----------------------- | --------------------------------- |
|
||||||
| Cmd + K | Open command palette |
|
| Cmd + K | Ctrl + K | Open command palette |
|
||||||
| Cmd + N | New connection |
|
| Cmd + N | Ctrl + N | New connection |
|
||||||
| Cmd + W | Close current tab |
|
| Cmd + W | Ctrl + W | Close current tab |
|
||||||
| Cmd + Tab | Next tab |
|
| Cmd + Tab | Ctrl + Tab | Next tab |
|
||||||
| Cmd + Shift + Tab | Previous tab |
|
| Cmd + Shift + Tab | Ctrl + Shift + Tab | Previous tab |
|
||||||
| Cmd + 1–9 | Switch to tab by number |
|
| Cmd + 1–9 | Ctrl + 1–9 | Switch to tab by number |
|
||||||
| Cmd + , | Open settings |
|
| Cmd + , | Ctrl + , | Open settings |
|
||||||
| Cmd + F | Find in terminal |
|
| Cmd + F | Ctrl + F | Find in terminal |
|
||||||
| Cmd + Shift + D | Split pane vertically |
|
| Cmd + Shift + D | Ctrl + Shift + D | Split pane vertically |
|
||||||
| Cmd + Shift + E | Split pane horizontally |
|
| Cmd + Shift + E | Ctrl + Shift + E | Split pane horizontally |
|
||||||
| Cmd + Shift + R | Toggle session recording |
|
| Cmd + Shift + R | Ctrl + Shift + R | Toggle session recording |
|
||||||
| Cmd + Shift + B | Toggle broadcast input mode |
|
| Cmd + Shift + B | Ctrl + Shift + B | Toggle broadcast input mode |
|
||||||
| Cmd + Shift + S | Open snippet library |
|
| Cmd + Shift + S | Ctrl + Shift + S | Open snippet library |
|
||||||
| Cmd + Shift + Q | Quick connect |
|
| Cmd + Shift + Q | Ctrl + Shift + Q | Quick connect |
|
||||||
| F11 | Toggle fullscreen (Windows/Linux) |
|
| F11 | F11 | Toggle fullscreen |
|
||||||
|
|||||||
+2
-2
@@ -94,9 +94,9 @@ build_target() {
|
|||||||
|
|
||||||
log "Building ${platform}…"
|
log "Building ${platform}…"
|
||||||
if [ -n "$cc" ]; then
|
if [ -n "$cc" ]; then
|
||||||
CC="$cc" CGO_ENABLED=1 wails build -platform "$platform"
|
CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "-s -w"
|
||||||
else
|
else
|
||||||
CGO_ENABLED=1 wails build -platform "$platform"
|
CGO_ENABLED=1 wails build -platform "$platform" -ldflags "-s -w"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function Build {
|
|||||||
|
|
||||||
$env:CGO_ENABLED = '1'
|
$env:CGO_ENABLED = '1'
|
||||||
Write-Info 'Building production binary (CGO_ENABLED=1)...'
|
Write-Info 'Building production binary (CGO_ENABLED=1)...'
|
||||||
& $wailsPath build
|
& $wailsPath build -ldflags "-s -w"
|
||||||
Write-Info 'Build complete: build\bin\'
|
Write-Info 'Build complete: build\bin\'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ build() {
|
|||||||
check_deps
|
check_deps
|
||||||
install_deps
|
install_deps
|
||||||
log "Building production binary…"
|
log "Building production binary…"
|
||||||
wails build
|
wails build -ldflags "-s -w"
|
||||||
log "Build complete: build/bin/"
|
log "Build complete: build/bin/"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-14
@@ -14,9 +14,9 @@ One-off session? Use **Cmd+Shift+Q** / **Ctrl+Shift+Q** for quick connect.
|
|||||||
## Daily workflow
|
## Daily workflow
|
||||||
|
|
||||||
- **Tabs and splits**: Drag tabs to reorder, and split the workspace to view multiple sessions at once.
|
- **Tabs and splits**: Drag tabs to reorder, and split the workspace to view multiple sessions at once.
|
||||||
- **Broadcast input**: Toggle with **Cmd+Shift+B** to send the same command to all connected sessions.
|
- **Broadcast input**: Toggle with **Cmd+Shift+B** (macOS) or **Ctrl+Shift+B** (Windows/Linux) to send the same command to all connected sessions.
|
||||||
- **Snippets**: Save common commands and run them with one click.
|
- **Snippets**: Save common commands and run them with one click.
|
||||||
- **Search**: Press **Cmd+F** to search within the active terminal.
|
- **Search**: Press **Cmd+F** (macOS) or **Ctrl+F** (Windows/Linux) to search within the active terminal.
|
||||||
|
|
||||||
## File transfers on SSH
|
## File transfers on SSH
|
||||||
|
|
||||||
@@ -42,18 +42,18 @@ The upload picker lists folders only and marks permissions:
|
|||||||
|
|
||||||
## Common shortcuts
|
## Common shortcuts
|
||||||
|
|
||||||
| Shortcut | Action |
|
| macOS Shortcut | Windows/Linux Shortcut | Action |
|
||||||
| ----------------- | --------------------------- |
|
| ------------- | ----------------------- | ------------------------ |
|
||||||
| Cmd + K | Command palette |
|
| Cmd + K | Ctrl + K | Command palette |
|
||||||
| Cmd + N | New connection |
|
| Cmd + N | Ctrl + N | New connection |
|
||||||
| Cmd + W | Close tab |
|
| Cmd + W | Ctrl + W | Close tab |
|
||||||
| Cmd + F | Find in terminal |
|
| Cmd + F | Ctrl + F | Find in terminal |
|
||||||
| Cmd + Shift + S | Snippet library |
|
| Cmd + Shift + S | Ctrl + Shift + S | Snippet library |
|
||||||
| Cmd + Shift + B | Broadcast input |
|
| Cmd + Shift + B | Ctrl + Shift + B | Broadcast input |
|
||||||
| Cmd + Shift + Q | Quick connect |
|
| Cmd + Shift + Q | Ctrl + Shift + Q | Quick connect |
|
||||||
| Cmd + Shift + R | Toggle session recording |
|
| Cmd + Shift + R | Ctrl + Shift + R | Toggle session recording |
|
||||||
| Cmd + Shift + D | Split pane vertically |
|
| Cmd + Shift + D | Ctrl + Shift + D | Split pane vertically |
|
||||||
| Cmd + Shift + E | Split pane horizontally |
|
| Cmd + Shift + E | Ctrl + Shift + E | Split pane horizontally |
|
||||||
|
|
||||||
## Need more detail?
|
## Need more detail?
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -122,7 +122,7 @@ Screenshot: Terminal context menu and search bar.
|
|||||||
|
|
||||||
### Search
|
### Search
|
||||||
|
|
||||||
Press **Cmd+F** to search in the active terminal. Toggle case sensitivity and regular expressions as needed.
|
Press **Cmd+F** (macOS) or **Ctrl+F** (Windows/Linux) to search in the active terminal. Toggle case sensitivity and regular expressions as needed.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -130,13 +130,13 @@ Press **Cmd+F** to search in the active terminal. Toggle case sensitivity and re
|
|||||||
|
|
||||||
### Snippets
|
### Snippets
|
||||||
|
|
||||||
Open the snippet library with **Cmd+Shift+S** and save frequently-used commands. Send a snippet to the active session with one click.
|
Open the snippet library with **Cmd+Shift+S** (macOS) or **Ctrl+Shift+S** (Windows/Linux) and save frequently-used commands. Send a snippet to the active session with one click.
|
||||||
|
|
||||||
Screenshot: Snippet library with search and folder grouping.
|
Screenshot: Snippet library with search and folder grouping.
|
||||||
|
|
||||||
### Broadcast input
|
### Broadcast input
|
||||||
|
|
||||||
Toggle broadcast mode with **Cmd+Shift+B**. While active, all input is sent to every connected session.
|
Toggle broadcast mode with **Cmd+Shift+B** (macOS) or **Ctrl+Shift+B** (Windows/Linux). While active, all input is sent to every connected session.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -184,7 +184,7 @@ Tip: Use the filter box to jump quickly to common folders.
|
|||||||
|
|
||||||
## 10. Session recordings
|
## 10. Session recordings
|
||||||
|
|
||||||
Toggle recording with **Cmd+Shift+R**. Recordings are saved as asciicast v2 files and can be replayed from the recordings panel.
|
Toggle recording with **Cmd+Shift+R** (macOS) or **Ctrl+Shift+R** (Windows/Linux). Recordings are saved as asciicast v2 files and can be replayed from the recordings panel.
|
||||||
|
|
||||||
Screenshot: Recordings list with playback controls.
|
Screenshot: Recordings list with playback controls.
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ Screenshot: TOTP configuration in the profile editor.
|
|||||||
|
|
||||||
## 12. Settings
|
## 12. Settings
|
||||||
|
|
||||||
Open settings with **Cmd+,** and configure:
|
Open settings with **Cmd+,** (macOS) or **Ctrl+,** (Windows/Linux) and configure:
|
||||||
|
|
||||||
- Terminal themes and fonts
|
- Terminal themes and fonts
|
||||||
- Default behaviours
|
- Default behaviours
|
||||||
|
|||||||
@@ -459,6 +459,13 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-success {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--success);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------
|
/* -----------------------------------------------------------------------
|
||||||
Toggle wrapper — label + toggle switch in a row
|
Toggle wrapper — label + toggle switch in a row
|
||||||
----------------------------------------------------------------------- */
|
----------------------------------------------------------------------- */
|
||||||
@@ -684,6 +691,21 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SSH key path row */
|
||||||
|
.key-path-row {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-path-row .form-input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-hosts-list .form-error {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.totp-toggle,
|
.totp-toggle,
|
||||||
.totp-test,
|
.totp-test,
|
||||||
.totp-save,
|
.totp-save,
|
||||||
|
|||||||
+64
-27
@@ -11,7 +11,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: var(--z-modal-backdrop);
|
z-index: var(--z-modal-backdrop);
|
||||||
background: rgba(10, 10, 20, 0.60);
|
background: rgba(10, 10, 20, 0.6);
|
||||||
backdrop-filter: blur(2px);
|
backdrop-filter: blur(2px);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
@@ -58,9 +58,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Size variants */
|
/* Size variants */
|
||||||
.modal-sm { width: 360px; }
|
.modal-sm {
|
||||||
.modal-lg { width: 640px; }
|
width: 360px;
|
||||||
.modal-xl { width: 800px; }
|
}
|
||||||
|
.modal-lg {
|
||||||
|
width: 640px;
|
||||||
|
}
|
||||||
|
.modal-xl {
|
||||||
|
width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -99,7 +105,9 @@
|
|||||||
font-size: var(--text-md);
|
font-size: var(--text-md);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition-fast), color var(--transition-fast);
|
transition:
|
||||||
|
background var(--transition-fast),
|
||||||
|
color var(--transition-fast);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.modal-close:hover {
|
.modal-close:hover {
|
||||||
@@ -152,7 +160,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: var(--z-modal-backdrop);
|
z-index: var(--z-modal-backdrop);
|
||||||
background: rgba(10, 10, 20, 0.50);
|
background: rgba(10, 10, 20, 0.5);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition:
|
transition:
|
||||||
@@ -187,8 +195,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Drawer width variants */
|
/* Drawer width variants */
|
||||||
.drawer-wide { width: 560px; }
|
.drawer-wide {
|
||||||
.drawer-narrow { width: 380px; }
|
width: 560px;
|
||||||
|
}
|
||||||
|
.drawer-narrow {
|
||||||
|
width: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
.drawer-header {
|
.drawer-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -216,7 +228,9 @@
|
|||||||
font-size: var(--text-md);
|
font-size: var(--text-md);
|
||||||
color: var(--text-tertiary);
|
color: var(--text-tertiary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition-fast), color var(--transition-fast);
|
transition:
|
||||||
|
background var(--transition-fast),
|
||||||
|
color var(--transition-fast);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.drawer-close:hover {
|
.drawer-close:hover {
|
||||||
@@ -271,7 +285,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drawer-proto-tab.active::after {
|
.drawer-proto-tab.active::after {
|
||||||
content: '';
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -1px;
|
bottom: -1px;
|
||||||
left: var(--space-2);
|
left: var(--space-2);
|
||||||
@@ -311,7 +325,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-tab.active::after {
|
.settings-tab.active::after {
|
||||||
content: '';
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: -1px;
|
bottom: -1px;
|
||||||
left: var(--space-1);
|
left: var(--space-1);
|
||||||
@@ -355,7 +369,9 @@
|
|||||||
border: none;
|
border: none;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition-fast), color var(--transition-fast);
|
transition:
|
||||||
|
background var(--transition-fast),
|
||||||
|
color var(--transition-fast);
|
||||||
border-left: 2px solid transparent;
|
border-left: 2px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,7 +417,8 @@
|
|||||||
margin: 0 0 var(--space-2) 0;
|
margin: 0 0 var(--space-2) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.help-content ul, .help-content ol {
|
.help-content ul,
|
||||||
|
.help-content ol {
|
||||||
margin: 0 0 var(--space-3) 0;
|
margin: 0 0 var(--space-3) 0;
|
||||||
padding-left: var(--space-5);
|
padding-left: var(--space-5);
|
||||||
}
|
}
|
||||||
@@ -411,7 +428,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.help-content code {
|
.help-content code {
|
||||||
font-family: 'Menlo', 'Consolas', 'Courier New', monospace;
|
font-family: "Menlo", "Consolas", "Courier New", monospace;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
background: var(--bg-base);
|
background: var(--bg-base);
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
@@ -421,7 +438,7 @@
|
|||||||
|
|
||||||
.help-content kbd {
|
.help-content kbd {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-family: 'Menlo', 'Consolas', 'Courier New', monospace;
|
font-family: "Menlo", "Consolas", "Courier New", monospace;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
background: var(--bg-base);
|
background: var(--bg-base);
|
||||||
@@ -465,7 +482,7 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: var(--z-modal-backdrop);
|
z-index: var(--z-modal-backdrop);
|
||||||
background: rgba(10, 10, 20, 0.50);
|
background: rgba(10, 10, 20, 0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -633,7 +650,7 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
padding: var(--space-3) var(--space-4);
|
padding: var(--space-3) var(--space-4);
|
||||||
background: rgba(239, 83, 80, 0.08);
|
background: rgba(239, 83, 80, 0.08);
|
||||||
border: 1px solid rgba(239, 83, 80, 0.20);
|
border: 1px solid rgba(239, 83, 80, 0.2);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
margin: var(--space-3) 0;
|
margin: var(--space-3) 0;
|
||||||
}
|
}
|
||||||
@@ -679,7 +696,9 @@
|
|||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background var(--transition-fast), color var(--transition-fast);
|
transition:
|
||||||
|
background var(--transition-fast),
|
||||||
|
color var(--transition-fast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-nav-item:hover {
|
.settings-nav-item:hover {
|
||||||
@@ -830,13 +849,23 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
width: 0;
|
width: 0;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
transition: width 0.3s ease, background-color 0.3s ease;
|
transition:
|
||||||
|
width 0.3s ease,
|
||||||
|
background-color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.strength-fill.strength-weak { background: var(--error); }
|
.strength-fill.strength-weak {
|
||||||
.strength-fill.strength-fair { background: var(--warning); }
|
background: var(--error);
|
||||||
.strength-fill.strength-good { background: #66bb6a; }
|
}
|
||||||
.strength-fill.strength-strong { background: var(--success); }
|
.strength-fill.strength-fair {
|
||||||
|
background: var(--warning);
|
||||||
|
}
|
||||||
|
.strength-fill.strength-good {
|
||||||
|
background: #66bb6a;
|
||||||
|
}
|
||||||
|
.strength-fill.strength-strong {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
.strength-label {
|
.strength-label {
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
@@ -845,7 +874,15 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.strength-label.strength-weak { color: var(--error); }
|
.strength-label.strength-weak {
|
||||||
.strength-label.strength-fair { color: var(--warning); }
|
color: var(--error);
|
||||||
.strength-label.strength-good { color: #66bb6a; }
|
}
|
||||||
.strength-label.strength-strong { color: var(--success); }
|
.strength-label.strength-fair {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
.strength-label.strength-good {
|
||||||
|
color: #66bb6a;
|
||||||
|
}
|
||||||
|
.strength-label.strength-strong {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,7 +74,9 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-sm);
|
||||||
transition: background var(--transition-fast), color var(--transition-fast);
|
transition:
|
||||||
|
background var(--transition-fast),
|
||||||
|
color var(--transition-fast);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -115,7 +117,9 @@
|
|||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: background var(--transition-fast), color var(--transition-fast);
|
transition:
|
||||||
|
background var(--transition-fast),
|
||||||
|
color var(--transition-fast);
|
||||||
max-width: 100px;
|
max-width: 100px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -273,7 +277,9 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
transition: background var(--transition-fast), color var(--transition-fast);
|
transition:
|
||||||
|
background var(--transition-fast),
|
||||||
|
color var(--transition-fast);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +338,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes sftp-spin {
|
@keyframes sftp-spin {
|
||||||
to { transform: rotate(360deg); }
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------
|
/* -----------------------------------------------------------------------
|
||||||
|
|||||||
@@ -65,6 +65,60 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------
|
||||||
|
Disconnected overlay
|
||||||
|
----------------------------------------------------------------------- */
|
||||||
|
.session-panel.is-disconnected > *:not(.terminal-disconnect-overlay) {
|
||||||
|
filter: blur(2px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-disconnect-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(10, 14, 18, 0.68);
|
||||||
|
z-index: 6;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity var(--transition-fast), visibility var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-disconnect-overlay.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-disconnect-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border-default);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-disconnect-title {
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
letter-spacing: 0.45em;
|
||||||
|
text-indent: 0.45em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: var(--weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-disconnect-message {
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
/* xterm.js overrides for consistent look */
|
/* xterm.js overrides for consistent look */
|
||||||
.terminal-container .xterm,
|
.terminal-container .xterm,
|
||||||
.session-panel .xterm {
|
.session-panel .xterm {
|
||||||
|
|||||||
@@ -168,6 +168,14 @@
|
|||||||
);
|
);
|
||||||
this.updateStatusBar();
|
this.updateStatusBar();
|
||||||
|
|
||||||
|
const showDisconnectedOverlay =
|
||||||
|
payload.state === "disconnected" || payload.state === "error";
|
||||||
|
this.terminals.setDisconnectedOverlay(
|
||||||
|
payload.sessionID,
|
||||||
|
showDisconnectedOverlay,
|
||||||
|
payload.error,
|
||||||
|
);
|
||||||
|
|
||||||
if (payload.state === "error" && payload.error) {
|
if (payload.state === "error" && payload.error) {
|
||||||
this._handleSessionError(payload.sessionID, payload.error);
|
this._handleSessionError(payload.sessionID, payload.error);
|
||||||
}
|
}
|
||||||
|
|||||||
+215
-10
@@ -82,6 +82,15 @@
|
|||||||
|
|
||||||
/** @type {boolean} */
|
/** @type {boolean} */
|
||||||
this._isNew = false;
|
this._isNew = false;
|
||||||
|
|
||||||
|
/** @type {string[]} */
|
||||||
|
this._sshKeyOptions = [];
|
||||||
|
|
||||||
|
/** @type {boolean} */
|
||||||
|
this._sshKeyOptionsLoaded = false;
|
||||||
|
|
||||||
|
/** @type {boolean} */
|
||||||
|
this._sshKeyOptionsLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Public API ───────────────────────────────────────────────────
|
// ── Public API ───────────────────────────────────────────────────
|
||||||
@@ -151,6 +160,7 @@
|
|||||||
|
|
||||||
// ── Scrollable body ─────────────────────────────────────
|
// ── Scrollable body ─────────────────────────────────────
|
||||||
const body = createElement("div", "drawer-body");
|
const body = createElement("div", "drawer-body");
|
||||||
|
const sshKeyDatalist = this._buildSSHKeyDatalist();
|
||||||
|
|
||||||
// General section (common to all protocols)
|
// General section (common to all protocols)
|
||||||
body.appendChild(this._renderGeneralSection(profile));
|
body.appendChild(this._renderGeneralSection(profile));
|
||||||
@@ -159,6 +169,7 @@
|
|||||||
switch (profile.protocol) {
|
switch (profile.protocol) {
|
||||||
case "ssh":
|
case "ssh":
|
||||||
this._renderSSHSections(profile).forEach((s) => body.appendChild(s));
|
this._renderSSHSections(profile).forEach((s) => body.appendChild(s));
|
||||||
|
body.appendChild(sshKeyDatalist);
|
||||||
break;
|
break;
|
||||||
case "telnet":
|
case "telnet":
|
||||||
this._renderTelnetSections(profile).forEach((s) =>
|
this._renderTelnetSections(profile).forEach((s) =>
|
||||||
@@ -240,6 +251,8 @@
|
|||||||
// Focus the first input
|
// Focus the first input
|
||||||
const firstInput = drawer.querySelector("input, select, textarea");
|
const firstInput = drawer.querySelector("input, select, textarea");
|
||||||
if (firstInput) firstInput.focus();
|
if (firstInput) firstInput.focus();
|
||||||
|
|
||||||
|
this._loadSSHKeyOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -377,6 +390,42 @@
|
|||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_buildSSHKeyDatalist() {
|
||||||
|
const list = createElement("datalist", "", {
|
||||||
|
id: "ssh-key-path-options",
|
||||||
|
});
|
||||||
|
this._applySSHKeyOptionsToList(list);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
_applySSHKeyOptionsToList(listEl) {
|
||||||
|
if (!listEl) return;
|
||||||
|
listEl.innerHTML = "";
|
||||||
|
for (const keyPath of this._sshKeyOptions) {
|
||||||
|
const opt = createElement("option", "", { value: keyPath });
|
||||||
|
listEl.appendChild(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_loadSSHKeyOptions() {
|
||||||
|
if (this._sshKeyOptionsLoading || this._sshKeyOptionsLoaded) return;
|
||||||
|
if (!window.go || !window.go.api || !window.go.api.Service) return;
|
||||||
|
|
||||||
|
this._sshKeyOptionsLoading = true;
|
||||||
|
window.go.api.Service.ListSSHKeys()
|
||||||
|
.then((keys) => {
|
||||||
|
this._sshKeyOptions = Array.isArray(keys) ? keys : [];
|
||||||
|
this._sshKeyOptionsLoaded = true;
|
||||||
|
this._sshKeyOptionsLoading = false;
|
||||||
|
if (!this.drawer) return;
|
||||||
|
const listEl = this.drawer.querySelector("#ssh-key-path-options");
|
||||||
|
this._applySSHKeyOptionsToList(listEl);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this._sshKeyOptionsLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** @private */
|
/** @private */
|
||||||
_renderSSHSections(profile) {
|
_renderSSHSections(profile) {
|
||||||
const ssh = profile.ssh || {};
|
const ssh = profile.ssh || {};
|
||||||
@@ -413,15 +462,7 @@
|
|||||||
// Key & Passphrase
|
// Key & Passphrase
|
||||||
sections.push(
|
sections.push(
|
||||||
this._renderSection("Key & Passphrase", [
|
this._renderSection("Key & Passphrase", [
|
||||||
this._field(
|
this._renderSSHKeyPathField(ssh.privateKeyPath),
|
||||||
"text",
|
|
||||||
"ssh.privateKeyPath",
|
|
||||||
"Private Key Path",
|
|
||||||
ssh.privateKeyPath,
|
|
||||||
{
|
|
||||||
placeholder: "~/.ssh/id_ed25519",
|
|
||||||
},
|
|
||||||
),
|
|
||||||
this._field(
|
this._field(
|
||||||
"select",
|
"select",
|
||||||
"ssh.keySecretMode",
|
"ssh.keySecretMode",
|
||||||
@@ -1522,6 +1563,10 @@
|
|||||||
|
|
||||||
group.appendChild(input);
|
group.appendChild(input);
|
||||||
|
|
||||||
|
if (opts.datalistId && input && input.tagName === "INPUT") {
|
||||||
|
input.setAttribute("list", opts.datalistId);
|
||||||
|
}
|
||||||
|
|
||||||
// Help text
|
// Help text
|
||||||
if (opts.help) {
|
if (opts.help) {
|
||||||
group.appendChild(
|
group.appendChild(
|
||||||
@@ -1532,6 +1577,64 @@
|
|||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_renderSSHKeyPathField(value) {
|
||||||
|
const group = createElement("div", "form-group");
|
||||||
|
const labelEl = createElement("label", "form-label", {
|
||||||
|
textContent: "Private Key Path",
|
||||||
|
for: "field-ssh.privateKeyPath",
|
||||||
|
});
|
||||||
|
group.appendChild(labelEl);
|
||||||
|
|
||||||
|
const row = createElement("div", "key-path-row");
|
||||||
|
const input = createElement("input", "form-input", {
|
||||||
|
type: "text",
|
||||||
|
id: "field-ssh.privateKeyPath",
|
||||||
|
name: "ssh.privateKeyPath",
|
||||||
|
value: value || "",
|
||||||
|
placeholder: "/Users/username/.ssh/id_ed25519",
|
||||||
|
});
|
||||||
|
input.setAttribute("list", "ssh-key-path-options");
|
||||||
|
row.appendChild(input);
|
||||||
|
|
||||||
|
const browseBtn = createElement("button", "btn btn-sm btn-outline", {
|
||||||
|
textContent: "Browse",
|
||||||
|
type: "button",
|
||||||
|
});
|
||||||
|
browseBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||||
|
browseBtn.addEventListener("click", () => {
|
||||||
|
if (!window.go || !window.go.api || !window.go.api.Service) return;
|
||||||
|
window.go.api.Service.SelectFile("Select SSH Private Key", "*")
|
||||||
|
.then((path) => {
|
||||||
|
if (path) input.value = path;
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this.app.notifications.error(
|
||||||
|
"Failed to open file dialog: " + (err.message || err),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
row.appendChild(browseBtn);
|
||||||
|
group.appendChild(row);
|
||||||
|
|
||||||
|
const error = createElement("span", "form-error");
|
||||||
|
error.style.display = "none";
|
||||||
|
group.appendChild(error);
|
||||||
|
|
||||||
|
const success = createElement("span", "form-success");
|
||||||
|
success.style.display = "none";
|
||||||
|
group.appendChild(success);
|
||||||
|
|
||||||
|
this._attachKeyPathValidation(input, error, success);
|
||||||
|
|
||||||
|
group.appendChild(
|
||||||
|
createElement("span", "form-hint", {
|
||||||
|
textContent: "Select a key from your .ssh folder or type a full path",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the jump-hosts list editor.
|
* Renders the jump-hosts list editor.
|
||||||
* @param {Array<Object>} jumpHosts
|
* @param {Array<Object>} jumpHosts
|
||||||
@@ -1551,15 +1654,41 @@
|
|||||||
<input class="form-input form-input-sm" name="jumpHost-${i}-username" value="${escapeHtml(jh.username || "")}" placeholder="User" />
|
<input class="form-input form-input-sm" name="jumpHost-${i}-username" value="${escapeHtml(jh.username || "")}" placeholder="User" />
|
||||||
</div>
|
</div>
|
||||||
<div class="jump-host-fields">
|
<div class="jump-host-fields">
|
||||||
<input class="form-input form-input-sm" name="jumpHost-${i}-privateKeyPath" value="${escapeHtml(jh.privateKeyPath || "")}" placeholder="Private key path (optional)" style="flex:1" />
|
<input class="form-input form-input-sm" name="jumpHost-${i}-privateKeyPath" value="${escapeHtml(jh.privateKeyPath || "")}" placeholder="Full key path (optional)" list="ssh-key-path-options" style="flex:1" />
|
||||||
|
<button class="btn btn-sm btn-outline jump-host-browse" type="button" title="Browse">Browse</button>
|
||||||
<button class="btn btn-icon btn-danger jump-host-remove" data-index="${i}" title="Remove">×</button>
|
<button class="btn btn-icon btn-danger jump-host-remove" data-index="${i}" title="Remove">×</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
const keyInput = row.querySelector(`input[name="jumpHost-${i}-privateKeyPath"]`);
|
||||||
|
const browseBtn = row.querySelector(".jump-host-browse");
|
||||||
|
const error = createElement("span", "form-error");
|
||||||
|
error.style.display = "none";
|
||||||
|
const success = createElement("span", "form-success");
|
||||||
|
success.style.display = "none";
|
||||||
|
|
||||||
|
browseBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||||
|
browseBtn.addEventListener("click", () => {
|
||||||
|
if (!window.go || !window.go.api || !window.go.api.Service) return;
|
||||||
|
window.go.api.Service.SelectFile("Select SSH Private Key", "*")
|
||||||
|
.then((path) => {
|
||||||
|
if (path) keyInput.value = path;
|
||||||
|
this._validateKeyPath(keyInput, error);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
this.app.notifications.error(
|
||||||
|
"Failed to open file dialog: " + (err.message || err),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this._attachKeyPathValidation(keyInput, error, success);
|
||||||
row.querySelector(".jump-host-remove").addEventListener("click", () => {
|
row.querySelector(".jump-host-remove").addEventListener("click", () => {
|
||||||
jumpHosts.splice(i, 1);
|
jumpHosts.splice(i, 1);
|
||||||
this.render(this._profile, this._isNew);
|
this.render(this._profile, this._isNew);
|
||||||
});
|
});
|
||||||
wrapper.appendChild(row);
|
wrapper.appendChild(row);
|
||||||
|
wrapper.appendChild(error);
|
||||||
|
wrapper.appendChild(success);
|
||||||
}
|
}
|
||||||
|
|
||||||
const addBtn = createElement("button", "btn btn-sm btn-outline", {
|
const addBtn = createElement("button", "btn btn-sm btn-outline", {
|
||||||
@@ -1581,6 +1710,82 @@
|
|||||||
return wrapper;
|
return wrapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_attachKeyPathValidation(input, errorEl, successEl) {
|
||||||
|
if (!input || !errorEl) return;
|
||||||
|
input.addEventListener("blur", () => this._validateKeyPath(input, errorEl, successEl));
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
errorEl.style.display = "none";
|
||||||
|
errorEl.textContent = "";
|
||||||
|
if (successEl) {
|
||||||
|
successEl.style.display = "none";
|
||||||
|
successEl.textContent = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_validateKeyPath(input, errorEl, successEl) {
|
||||||
|
const value = (input.value || "").trim();
|
||||||
|
if (!value) {
|
||||||
|
errorEl.style.display = "none";
|
||||||
|
errorEl.textContent = "";
|
||||||
|
if (successEl) {
|
||||||
|
successEl.style.display = "none";
|
||||||
|
successEl.textContent = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value.startsWith("~")) {
|
||||||
|
errorEl.textContent = "Please use a full path (no ~).";
|
||||||
|
errorEl.style.display = "block";
|
||||||
|
if (successEl) {
|
||||||
|
successEl.style.display = "none";
|
||||||
|
successEl.textContent = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.go || !window.go.api || !window.go.api.Service) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.go.api.Service.FileExists(value)
|
||||||
|
.then((exists) => {
|
||||||
|
if (!exists) {
|
||||||
|
errorEl.textContent = "Key file not found.";
|
||||||
|
errorEl.style.display = "block";
|
||||||
|
if (successEl) {
|
||||||
|
successEl.style.display = "none";
|
||||||
|
successEl.textContent = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return window.go.api.Service.FileIsPrivateKey(value)
|
||||||
|
.then((isKey) => {
|
||||||
|
if (!isKey) {
|
||||||
|
errorEl.textContent = "File is not a recognised private key.";
|
||||||
|
errorEl.style.display = "block";
|
||||||
|
if (successEl) {
|
||||||
|
successEl.style.display = "none";
|
||||||
|
successEl.textContent = "";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
errorEl.style.display = "none";
|
||||||
|
errorEl.textContent = "";
|
||||||
|
if (successEl) {
|
||||||
|
successEl.textContent = "Key verified.";
|
||||||
|
successEl.style.display = "block";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
errorEl.textContent = "Could not verify this path.";
|
||||||
|
errorEl.style.display = "block";
|
||||||
|
if (successEl) {
|
||||||
|
successEl.style.display = "none";
|
||||||
|
successEl.textContent = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the port-forwarding rules list editor.
|
* Renders the port-forwarding rules list editor.
|
||||||
* @param {Array<Object>} portForwards
|
* @param {Array<Object>} portForwards
|
||||||
|
|||||||
+351
-206
@@ -1,70 +1,120 @@
|
|||||||
(function() {
|
(function () {
|
||||||
/**
|
/**
|
||||||
* Nexus Terminal — SFTP File Browser
|
* Nexus Terminal — SFTP File Browser
|
||||||
*
|
*
|
||||||
* Provides a right-side panel for browsing, uploading, downloading,
|
* Provides a right-side panel for browsing, uploading, downloading,
|
||||||
* renaming and deleting remote files alongside the terminal.
|
* renaming and deleting remote files alongside the terminal.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const {
|
const {
|
||||||
createElement, escapeHtml, formatBytes, relativeTime, dismissContextMenus,
|
createElement,
|
||||||
} = window.NexusTerminal;
|
escapeHtml,
|
||||||
|
formatBytes,
|
||||||
|
relativeTime,
|
||||||
|
dismissContextMenus,
|
||||||
|
} = window.NexusTerminal;
|
||||||
|
|
||||||
/* ── Helpers ──────────────────────────────────────────────────────── */
|
/* ── Helpers ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function pathJoin(base, name) {
|
function pathJoin(base, name) {
|
||||||
if (base === '/') return '/' + name;
|
if (base === "/") return "/" + name;
|
||||||
return base.replace(/\/+$/, '') + '/' + name;
|
return base.replace(/\/+$/, "") + "/" + name;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pathSegments(p) {
|
function pathSegments(p) {
|
||||||
const parts = p.split('/').filter(Boolean);
|
const parts = p.split("/").filter(Boolean);
|
||||||
var segs = [{ label: '/', path: '/' }];
|
var segs = [{ label: "/", path: "/" }];
|
||||||
var acc = '';
|
var acc = "";
|
||||||
for (var i = 0; i < parts.length; i++) {
|
for (var i = 0; i < parts.length; i++) {
|
||||||
acc += '/' + parts[i];
|
acc += "/" + parts[i];
|
||||||
segs.push({ label: parts[i], path: acc });
|
segs.push({ label: parts[i], path: acc });
|
||||||
}
|
}
|
||||||
return segs;
|
return segs;
|
||||||
}
|
|
||||||
|
|
||||||
function parentPath(p) {
|
|
||||||
if (p === '/') return '/';
|
|
||||||
var trimmed = p.replace(/\/+$/, '');
|
|
||||||
var idx = trimmed.lastIndexOf('/');
|
|
||||||
return idx <= 0 ? '/' : trimmed.substring(0, idx);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileIcon(entry) {
|
|
||||||
if (entry.isDir) return '📁';
|
|
||||||
var ext = (entry.name.split('.').pop() || '').toLowerCase();
|
|
||||||
switch (ext) {
|
|
||||||
case 'jpg': case 'jpeg': case 'png': case 'gif': case 'svg': case 'webp': return '🖼';
|
|
||||||
case 'mp3': case 'wav': case 'ogg': case 'flac': return '🎵';
|
|
||||||
case 'mp4': case 'avi': case 'mkv': case 'mov': return '🎬';
|
|
||||||
case 'zip': case 'tar': case 'gz': case 'bz2': case 'xz': case '7z': return '📦';
|
|
||||||
case 'sh': case 'bash': case 'zsh': return '⚙';
|
|
||||||
case 'py': case 'js': case 'ts': case 'go': case 'rs': case 'c': case 'h': case 'cpp': case 'java': return '◇';
|
|
||||||
case 'md': case 'txt': case 'log': case 'csv': return '📝';
|
|
||||||
case 'json': case 'yaml': case 'yml': case 'toml': case 'xml': return '⚙';
|
|
||||||
case 'key': case 'pem': case 'crt': case 'pub': return '🔑';
|
|
||||||
default: return '📄';
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function modeString(mode) {
|
function parentPath(p) {
|
||||||
if (!mode && mode !== 0) return '';
|
if (p === "/") return "/";
|
||||||
var s = '';
|
var trimmed = p.replace(/\/+$/, "");
|
||||||
var perms = ['---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx'];
|
var idx = trimmed.lastIndexOf("/");
|
||||||
|
return idx <= 0 ? "/" : trimmed.substring(0, idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileIcon(entry) {
|
||||||
|
if (entry.isDir) return "📁";
|
||||||
|
var ext = (entry.name.split(".").pop() || "").toLowerCase();
|
||||||
|
switch (ext) {
|
||||||
|
case "jpg":
|
||||||
|
case "jpeg":
|
||||||
|
case "png":
|
||||||
|
case "gif":
|
||||||
|
case "svg":
|
||||||
|
case "webp":
|
||||||
|
return "🖼";
|
||||||
|
case "mp3":
|
||||||
|
case "wav":
|
||||||
|
case "ogg":
|
||||||
|
case "flac":
|
||||||
|
return "🎵";
|
||||||
|
case "mp4":
|
||||||
|
case "avi":
|
||||||
|
case "mkv":
|
||||||
|
case "mov":
|
||||||
|
return "🎬";
|
||||||
|
case "zip":
|
||||||
|
case "tar":
|
||||||
|
case "gz":
|
||||||
|
case "bz2":
|
||||||
|
case "xz":
|
||||||
|
case "7z":
|
||||||
|
return "📦";
|
||||||
|
case "sh":
|
||||||
|
case "bash":
|
||||||
|
case "zsh":
|
||||||
|
return "⚙";
|
||||||
|
case "py":
|
||||||
|
case "js":
|
||||||
|
case "ts":
|
||||||
|
case "go":
|
||||||
|
case "rs":
|
||||||
|
case "c":
|
||||||
|
case "h":
|
||||||
|
case "cpp":
|
||||||
|
case "java":
|
||||||
|
return "◇";
|
||||||
|
case "md":
|
||||||
|
case "txt":
|
||||||
|
case "log":
|
||||||
|
case "csv":
|
||||||
|
return "📝";
|
||||||
|
case "json":
|
||||||
|
case "yaml":
|
||||||
|
case "yml":
|
||||||
|
case "toml":
|
||||||
|
case "xml":
|
||||||
|
return "⚙";
|
||||||
|
case "key":
|
||||||
|
case "pem":
|
||||||
|
case "crt":
|
||||||
|
case "pub":
|
||||||
|
return "🔑";
|
||||||
|
default:
|
||||||
|
return "📄";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeString(mode) {
|
||||||
|
if (!mode && mode !== 0) return "";
|
||||||
|
var s = "";
|
||||||
|
var perms = ["---", "--x", "-w-", "-wx", "r--", "r-x", "rw-", "rwx"];
|
||||||
s += perms[(mode >> 6) & 7];
|
s += perms[(mode >> 6) & 7];
|
||||||
s += perms[(mode >> 3) & 7];
|
s += perms[(mode >> 3) & 7];
|
||||||
s += perms[mode & 7];
|
s += perms[mode & 7];
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── SFTPBrowser class ────────────────────────────────────────────── */
|
/* ── SFTPBrowser class ────────────────────────────────────────────── */
|
||||||
|
|
||||||
class SFTPBrowser {
|
class SFTPBrowser {
|
||||||
/**
|
/**
|
||||||
* @param {App} app — the application instance
|
* @param {App} app — the application instance
|
||||||
*/
|
*/
|
||||||
@@ -83,24 +133,26 @@ class SFTPBrowser {
|
|||||||
async open(sessionId) {
|
async open(sessionId) {
|
||||||
if (this.panels.has(sessionId)) return;
|
if (this.panels.has(sessionId)) return;
|
||||||
|
|
||||||
var sessionPanel = document.getElementById('panel-' + sessionId);
|
var sessionPanel = document.getElementById("panel-" + sessionId);
|
||||||
if (!sessionPanel) {
|
if (!sessionPanel) {
|
||||||
this.app.notifications.error('Cannot open file browser: session panel not found.');
|
this.app.notifications.error(
|
||||||
|
"Cannot open file browser: session panel not found.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var state = {
|
var state = {
|
||||||
el: null,
|
el: null,
|
||||||
handle: null,
|
handle: null,
|
||||||
cwd: '/',
|
cwd: "/",
|
||||||
files: [],
|
files: [],
|
||||||
selected: null,
|
selected: null,
|
||||||
loading: false,
|
loading: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build the panel DOM
|
// Build the panel DOM
|
||||||
var panel = createElement('div', 'sftp-panel');
|
var panel = createElement("div", "sftp-panel");
|
||||||
var handle = createElement('div', 'sftp-resize-handle');
|
var handle = createElement("div", "sftp-resize-handle");
|
||||||
panel.appendChild(handle);
|
panel.appendChild(handle);
|
||||||
state.el = panel;
|
state.el = panel;
|
||||||
state.handle = handle;
|
state.handle = handle;
|
||||||
@@ -110,12 +162,12 @@ class SFTPBrowser {
|
|||||||
panel.appendChild(toolbar);
|
panel.appendChild(toolbar);
|
||||||
|
|
||||||
// File list container
|
// File list container
|
||||||
var fileList = createElement('div', 'sftp-file-list');
|
var fileList = createElement("div", "sftp-file-list");
|
||||||
panel.appendChild(fileList);
|
panel.appendChild(fileList);
|
||||||
|
|
||||||
// Context action bar (hidden initially)
|
// Context action bar (hidden initially)
|
||||||
var ctxBar = createElement('div', 'sftp-context-bar');
|
var ctxBar = createElement("div", "sftp-context-bar");
|
||||||
ctxBar.style.display = 'none';
|
ctxBar.style.display = "none";
|
||||||
panel.appendChild(ctxBar);
|
panel.appendChild(ctxBar);
|
||||||
|
|
||||||
this.panels.set(sessionId, state);
|
this.panels.set(sessionId, state);
|
||||||
@@ -165,34 +217,34 @@ class SFTPBrowser {
|
|||||||
*/
|
*/
|
||||||
getCwd(sessionId) {
|
getCwd(sessionId) {
|
||||||
var state = this.panels.get(sessionId);
|
var state = this.panels.get(sessionId);
|
||||||
return state ? state.cwd : '';
|
return state ? state.cwd : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Toolbar ──────────────────────────────────────────────────── */
|
/* ── Toolbar ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
_buildToolbar(sessionId, state) {
|
_buildToolbar(sessionId, state) {
|
||||||
var toolbar = createElement('div', 'sftp-toolbar');
|
var toolbar = createElement("div", "sftp-toolbar");
|
||||||
|
|
||||||
var breadcrumb = createElement('div', 'sftp-breadcrumb');
|
var breadcrumb = createElement("div", "sftp-breadcrumb");
|
||||||
breadcrumb.dataset.role = 'breadcrumb';
|
breadcrumb.dataset.role = "breadcrumb";
|
||||||
toolbar.appendChild(breadcrumb);
|
toolbar.appendChild(breadcrumb);
|
||||||
|
|
||||||
var actions = createElement('div', 'sftp-toolbar-actions');
|
var actions = createElement("div", "sftp-toolbar-actions");
|
||||||
|
|
||||||
var mkdirBtn = this._toolbarBtn('+', 'New folder', 'is-accent');
|
var mkdirBtn = this._toolbarBtn("+", "New folder", "is-accent");
|
||||||
mkdirBtn.addEventListener('click', () => this._promptMkdir(sessionId));
|
mkdirBtn.addEventListener("click", () => this._promptMkdir(sessionId));
|
||||||
actions.appendChild(mkdirBtn);
|
actions.appendChild(mkdirBtn);
|
||||||
|
|
||||||
var uploadBtn = this._toolbarBtn('↑', 'Upload file', 'is-accent');
|
var uploadBtn = this._toolbarBtn("↑", "Upload file", "is-accent");
|
||||||
uploadBtn.addEventListener('click', () => this._upload(sessionId));
|
uploadBtn.addEventListener("click", () => this._upload(sessionId));
|
||||||
actions.appendChild(uploadBtn);
|
actions.appendChild(uploadBtn);
|
||||||
|
|
||||||
var refreshBtn = this._toolbarBtn('⟳', 'Refresh');
|
var refreshBtn = this._toolbarBtn("⟳", "Refresh");
|
||||||
refreshBtn.addEventListener('click', () => this._refresh(sessionId));
|
refreshBtn.addEventListener("click", () => this._refresh(sessionId));
|
||||||
actions.appendChild(refreshBtn);
|
actions.appendChild(refreshBtn);
|
||||||
|
|
||||||
var closeBtn = this._toolbarBtn('✕', 'Close', 'is-close');
|
var closeBtn = this._toolbarBtn("✕", "Close", "is-close");
|
||||||
closeBtn.addEventListener('click', () => this.close(sessionId));
|
closeBtn.addEventListener("click", () => this.close(sessionId));
|
||||||
actions.appendChild(closeBtn);
|
actions.appendChild(closeBtn);
|
||||||
|
|
||||||
toolbar.appendChild(actions);
|
toolbar.appendChild(actions);
|
||||||
@@ -200,8 +252,8 @@ class SFTPBrowser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_toolbarBtn(label, title, extraClass) {
|
_toolbarBtn(label, title, extraClass) {
|
||||||
var cls = 'sftp-toolbar-btn' + (extraClass ? ' ' + extraClass : '');
|
var cls = "sftp-toolbar-btn" + (extraClass ? " " + extraClass : "");
|
||||||
return createElement('button', cls, { textContent: label, title: title });
|
return createElement("button", cls, { textContent: label, title: title });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Navigation ───────────────────────────────────────────────── */
|
/* ── Navigation ───────────────────────────────────────────────── */
|
||||||
@@ -240,10 +292,10 @@ class SFTPBrowser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_sortFiles(files) {
|
_sortFiles(files) {
|
||||||
return files.slice().sort(function(a, b) {
|
return files.slice().sort(function (a, b) {
|
||||||
// Directories first
|
// Directories first
|
||||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,33 +304,37 @@ class SFTPBrowser {
|
|||||||
_getFileList(sessionId) {
|
_getFileList(sessionId) {
|
||||||
var state = this.panels.get(sessionId);
|
var state = this.panels.get(sessionId);
|
||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
return state.el.querySelector('.sftp-file-list');
|
return state.el.querySelector(".sftp-file-list");
|
||||||
}
|
}
|
||||||
|
|
||||||
_getCtxBar(sessionId) {
|
_getCtxBar(sessionId) {
|
||||||
var state = this.panels.get(sessionId);
|
var state = this.panels.get(sessionId);
|
||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
return state.el.querySelector('.sftp-context-bar');
|
return state.el.querySelector(".sftp-context-bar");
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderBreadcrumb(sessionId) {
|
_renderBreadcrumb(sessionId) {
|
||||||
var state = this.panels.get(sessionId);
|
var state = this.panels.get(sessionId);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
var container = state.el.querySelector('[data-role="breadcrumb"]');
|
var container = state.el.querySelector('[data-role="breadcrumb"]');
|
||||||
container.innerHTML = '';
|
container.innerHTML = "";
|
||||||
|
|
||||||
var segs = pathSegments(state.cwd);
|
var segs = pathSegments(state.cwd);
|
||||||
var self = this;
|
var self = this;
|
||||||
for (var i = 0; i < segs.length; i++) {
|
for (var i = 0; i < segs.length; i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
container.appendChild(createElement('span', 'sftp-breadcrumb-sep', { textContent: '›' }));
|
container.appendChild(
|
||||||
|
createElement("span", "sftp-breadcrumb-sep", { textContent: "›" }),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
var btn = createElement('button', 'sftp-breadcrumb-segment', {
|
var btn = createElement("button", "sftp-breadcrumb-segment", {
|
||||||
textContent: segs[i].label,
|
textContent: segs[i].label,
|
||||||
title: segs[i].path,
|
title: segs[i].path,
|
||||||
});
|
});
|
||||||
(function(p) {
|
(function (p) {
|
||||||
btn.addEventListener('click', function() { self._navigate(sessionId, p); });
|
btn.addEventListener("click", function () {
|
||||||
|
self._navigate(sessionId, p);
|
||||||
|
});
|
||||||
})(segs[i].path);
|
})(segs[i].path);
|
||||||
container.appendChild(btn);
|
container.appendChild(btn);
|
||||||
}
|
}
|
||||||
@@ -288,28 +344,33 @@ class SFTPBrowser {
|
|||||||
var state = this.panels.get(sessionId);
|
var state = this.panels.get(sessionId);
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
var list = this._getFileList(sessionId);
|
var list = this._getFileList(sessionId);
|
||||||
list.innerHTML = '';
|
list.innerHTML = "";
|
||||||
|
|
||||||
// Hide context bar
|
// Hide context bar
|
||||||
var ctxBar = this._getCtxBar(sessionId);
|
var ctxBar = this._getCtxBar(sessionId);
|
||||||
if (ctxBar) ctxBar.style.display = 'none';
|
if (ctxBar) ctxBar.style.display = "none";
|
||||||
|
|
||||||
// Parent directory row if not at root
|
// Parent directory row if not at root
|
||||||
if (state.cwd !== '/') {
|
if (state.cwd !== "/") {
|
||||||
var parentRow = this._buildFileRow({
|
var parentRow = this._buildFileRow(
|
||||||
name: '..',
|
{
|
||||||
|
name: "..",
|
||||||
isDir: true,
|
isDir: true,
|
||||||
path: parentPath(state.cwd),
|
path: parentPath(state.cwd),
|
||||||
size: 0,
|
size: 0,
|
||||||
modifiedAt: 0,
|
modifiedAt: 0,
|
||||||
}, sessionId, true);
|
},
|
||||||
parentRow.classList.add('is-parent');
|
sessionId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
parentRow.classList.add("is-parent");
|
||||||
list.appendChild(parentRow);
|
list.appendChild(parentRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.files.length === 0) {
|
if (state.files.length === 0) {
|
||||||
var empty = createElement('div', 'sftp-state');
|
var empty = createElement("div", "sftp-state");
|
||||||
empty.innerHTML = '<div class="sftp-state-icon">📂</div><div class="sftp-state-msg">Empty directory</div>';
|
empty.innerHTML =
|
||||||
|
'<div class="sftp-state-icon">📂</div><div class="sftp-state-msg">Empty directory</div>';
|
||||||
list.appendChild(empty);
|
list.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -321,42 +382,49 @@ class SFTPBrowser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_buildFileRow(entry, sessionId, isParent) {
|
_buildFileRow(entry, sessionId, isParent) {
|
||||||
var row = createElement('div', 'sftp-file-row');
|
var row = createElement("div", "sftp-file-row");
|
||||||
var self = this;
|
var self = this;
|
||||||
|
|
||||||
// Icon
|
// Icon
|
||||||
var icon = createElement('span', 'sftp-file-icon' + (entry.isDir ? ' is-dir' : ''));
|
var icon = createElement(
|
||||||
icon.textContent = isParent ? '↩' : fileIcon(entry);
|
"span",
|
||||||
|
"sftp-file-icon" + (entry.isDir ? " is-dir" : ""),
|
||||||
|
);
|
||||||
|
icon.textContent = isParent ? "↩" : fileIcon(entry);
|
||||||
row.appendChild(icon);
|
row.appendChild(icon);
|
||||||
|
|
||||||
// Name
|
// Name
|
||||||
var name = createElement('span', 'sftp-file-name', {
|
var name = createElement("span", "sftp-file-name", {
|
||||||
textContent: entry.name,
|
textContent: entry.name,
|
||||||
title: entry.name,
|
title: entry.name,
|
||||||
});
|
});
|
||||||
row.appendChild(name);
|
row.appendChild(name);
|
||||||
|
|
||||||
// Meta (size or date)
|
// Meta (size or date)
|
||||||
var meta = createElement('span', 'sftp-file-meta');
|
var meta = createElement("span", "sftp-file-meta");
|
||||||
if (!isParent && !entry.isDir) {
|
if (!isParent && !entry.isDir) {
|
||||||
meta.textContent = formatBytes(entry.size || 0);
|
meta.textContent = formatBytes(entry.size || 0);
|
||||||
meta.title = entry.modifiedAt ? new Date(entry.modifiedAt).toLocaleString() : '';
|
meta.title = entry.modifiedAt
|
||||||
|
? new Date(entry.modifiedAt).toLocaleString()
|
||||||
|
: "";
|
||||||
} else if (!isParent && entry.isDir) {
|
} else if (!isParent && entry.isDir) {
|
||||||
meta.textContent = '';
|
meta.textContent = "";
|
||||||
}
|
}
|
||||||
row.appendChild(meta);
|
row.appendChild(meta);
|
||||||
|
|
||||||
// Double-click: navigate into directory
|
// Double-click: navigate into directory
|
||||||
if (entry.isDir) {
|
if (entry.isDir) {
|
||||||
row.addEventListener('dblclick', function() {
|
row.addEventListener("dblclick", function () {
|
||||||
var target = isParent ? parentPath(self.panels.get(sessionId).cwd) : entry.path;
|
var target = isParent
|
||||||
|
? parentPath(self.panels.get(sessionId).cwd)
|
||||||
|
: entry.path;
|
||||||
self._navigate(sessionId, target);
|
self._navigate(sessionId, target);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single click: select (files only, or directories too for context actions)
|
// Single click: select (files only, or directories too for context actions)
|
||||||
if (!isParent) {
|
if (!isParent) {
|
||||||
row.addEventListener('click', function(e) {
|
row.addEventListener("click", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
self._selectFile(sessionId, entry, row);
|
self._selectFile(sessionId, entry, row);
|
||||||
});
|
});
|
||||||
@@ -368,27 +436,32 @@ class SFTPBrowser {
|
|||||||
_renderLoading(sessionId) {
|
_renderLoading(sessionId) {
|
||||||
var list = this._getFileList(sessionId);
|
var list = this._getFileList(sessionId);
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
list.innerHTML = '';
|
list.innerHTML = "";
|
||||||
var s = createElement('div', 'sftp-state');
|
var s = createElement("div", "sftp-state");
|
||||||
s.innerHTML = '<div class="sftp-spinner"></div><div class="sftp-state-msg">Loading…</div>';
|
s.innerHTML =
|
||||||
|
'<div class="sftp-spinner"></div><div class="sftp-state-msg">Loading…</div>';
|
||||||
list.appendChild(s);
|
list.appendChild(s);
|
||||||
var ctxBar = this._getCtxBar(sessionId);
|
var ctxBar = this._getCtxBar(sessionId);
|
||||||
if (ctxBar) ctxBar.style.display = 'none';
|
if (ctxBar) ctxBar.style.display = "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
_renderError(sessionId, msg) {
|
_renderError(sessionId, msg) {
|
||||||
var list = this._getFileList(sessionId);
|
var list = this._getFileList(sessionId);
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
list.innerHTML = '';
|
list.innerHTML = "";
|
||||||
var s = createElement('div', 'sftp-state');
|
var s = createElement("div", "sftp-state");
|
||||||
s.innerHTML =
|
s.innerHTML =
|
||||||
'<div class="sftp-state-icon">⚠</div>' +
|
'<div class="sftp-state-icon">⚠</div>' +
|
||||||
'<div class="sftp-state-msg">' + escapeHtml(msg) + '</div>' +
|
'<div class="sftp-state-msg">' +
|
||||||
|
escapeHtml(msg) +
|
||||||
|
"</div>" +
|
||||||
'<button class="btn btn-sm btn-secondary sftp-state-retry">Retry</button>';
|
'<button class="btn btn-sm btn-secondary sftp-state-retry">Retry</button>';
|
||||||
var self = this;
|
var self = this;
|
||||||
var retryBtn = s.querySelector('.sftp-state-retry');
|
var retryBtn = s.querySelector(".sftp-state-retry");
|
||||||
if (retryBtn) {
|
if (retryBtn) {
|
||||||
retryBtn.addEventListener('click', function() { self._refresh(sessionId); });
|
retryBtn.addEventListener("click", function () {
|
||||||
|
self._refresh(sessionId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
list.appendChild(s);
|
list.appendChild(s);
|
||||||
}
|
}
|
||||||
@@ -400,19 +473,19 @@ class SFTPBrowser {
|
|||||||
if (!state) return;
|
if (!state) return;
|
||||||
|
|
||||||
// Deselect previous
|
// Deselect previous
|
||||||
var prev = state.el.querySelector('.sftp-file-row.is-selected');
|
var prev = state.el.querySelector(".sftp-file-row.is-selected");
|
||||||
if (prev) prev.classList.remove('is-selected');
|
if (prev) prev.classList.remove("is-selected");
|
||||||
|
|
||||||
// If clicking the same entry, deselect
|
// If clicking the same entry, deselect
|
||||||
if (state.selected === entry.path) {
|
if (state.selected === entry.path) {
|
||||||
state.selected = null;
|
state.selected = null;
|
||||||
var ctxBar = this._getCtxBar(sessionId);
|
var ctxBar = this._getCtxBar(sessionId);
|
||||||
if (ctxBar) ctxBar.style.display = 'none';
|
if (ctxBar) ctxBar.style.display = "none";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.selected = entry.path;
|
state.selected = entry.path;
|
||||||
rowEl.classList.add('is-selected');
|
rowEl.classList.add("is-selected");
|
||||||
this._showContextBar(sessionId, entry);
|
this._showContextBar(sessionId, entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,10 +493,10 @@ class SFTPBrowser {
|
|||||||
var ctxBar = this._getCtxBar(sessionId);
|
var ctxBar = this._getCtxBar(sessionId);
|
||||||
if (!ctxBar) return;
|
if (!ctxBar) return;
|
||||||
|
|
||||||
ctxBar.innerHTML = '';
|
ctxBar.innerHTML = "";
|
||||||
ctxBar.style.display = 'flex';
|
ctxBar.style.display = "flex";
|
||||||
|
|
||||||
var label = createElement('span', 'sftp-ctx-label', {
|
var label = createElement("span", "sftp-ctx-label", {
|
||||||
textContent: entry.name,
|
textContent: entry.name,
|
||||||
title: entry.path,
|
title: entry.path,
|
||||||
});
|
});
|
||||||
@@ -432,17 +505,29 @@ class SFTPBrowser {
|
|||||||
var self = this;
|
var self = this;
|
||||||
|
|
||||||
if (!entry.isDir) {
|
if (!entry.isDir) {
|
||||||
var dlBtn = createElement('button', 'sftp-ctx-btn is-accent', { textContent: '↓ Download' });
|
var dlBtn = createElement("button", "sftp-ctx-btn is-accent", {
|
||||||
dlBtn.addEventListener('click', function() { self._download(sessionId, entry.path); });
|
textContent: "↓ Download",
|
||||||
|
});
|
||||||
|
dlBtn.addEventListener("click", function () {
|
||||||
|
self._download(sessionId, entry.path);
|
||||||
|
});
|
||||||
ctxBar.appendChild(dlBtn);
|
ctxBar.appendChild(dlBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
var renameBtn = createElement('button', 'sftp-ctx-btn', { textContent: '✎ Rename' });
|
var renameBtn = createElement("button", "sftp-ctx-btn", {
|
||||||
renameBtn.addEventListener('click', function() { self._promptRename(sessionId, entry); });
|
textContent: "✎ Rename",
|
||||||
|
});
|
||||||
|
renameBtn.addEventListener("click", function () {
|
||||||
|
self._promptRename(sessionId, entry);
|
||||||
|
});
|
||||||
ctxBar.appendChild(renameBtn);
|
ctxBar.appendChild(renameBtn);
|
||||||
|
|
||||||
var delBtn = createElement('button', 'sftp-ctx-btn is-danger', { textContent: '✕ Delete' });
|
var delBtn = createElement("button", "sftp-ctx-btn is-danger", {
|
||||||
delBtn.addEventListener('click', function() { self._confirmDelete(sessionId, entry); });
|
textContent: "✕ Delete",
|
||||||
|
});
|
||||||
|
delBtn.addEventListener("click", function () {
|
||||||
|
self._confirmDelete(sessionId, entry);
|
||||||
|
});
|
||||||
ctxBar.appendChild(delBtn);
|
ctxBar.appendChild(delBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,9 +536,11 @@ class SFTPBrowser {
|
|||||||
async _download(sessionId, filePath) {
|
async _download(sessionId, filePath) {
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.SFTPDownload(sessionId, filePath);
|
await window.go.api.Service.SFTPDownload(sessionId, filePath);
|
||||||
this.app.notifications.success('Download started');
|
this.app.notifications.success("Download started");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error('Download failed: ' + (err.message || err));
|
this.app.notifications.error(
|
||||||
|
"Download failed: " + (err.message || err),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,13 +549,13 @@ class SFTPBrowser {
|
|||||||
if (!state) return;
|
if (!state) return;
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.SFTPUpload(sessionId, state.cwd);
|
await window.go.api.Service.SFTPUpload(sessionId, state.cwd);
|
||||||
this.app.notifications.success('Upload complete');
|
this.app.notifications.success("Upload complete");
|
||||||
await this._refresh(sessionId);
|
await this._refresh(sessionId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// User may have cancelled the dialog — only show error if meaningful
|
// User may have cancelled the dialog — only show error if meaningful
|
||||||
var msg = err.message || String(err);
|
var msg = err.message || String(err);
|
||||||
if (msg && msg.toLowerCase().indexOf('cancel') === -1) {
|
if (msg && msg.toLowerCase().indexOf("cancel") === -1) {
|
||||||
this.app.notifications.error('Upload failed: ' + msg);
|
this.app.notifications.error("Upload failed: " + msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -479,10 +566,10 @@ class SFTPBrowser {
|
|||||||
var fullPath = pathJoin(state.cwd, name);
|
var fullPath = pathJoin(state.cwd, name);
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.SFTPMkdir(sessionId, fullPath);
|
await window.go.api.Service.SFTPMkdir(sessionId, fullPath);
|
||||||
this.app.notifications.success('Directory created');
|
this.app.notifications.success("Directory created");
|
||||||
await this._refresh(sessionId);
|
await this._refresh(sessionId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error('mkdir failed: ' + (err.message || err));
|
this.app.notifications.error("mkdir failed: " + (err.message || err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -492,20 +579,22 @@ class SFTPBrowser {
|
|||||||
var newPath = pathJoin(parentPath(oldPath), newName);
|
var newPath = pathJoin(parentPath(oldPath), newName);
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.SFTPRename(sessionId, oldPath, newPath);
|
await window.go.api.Service.SFTPRename(sessionId, oldPath, newPath);
|
||||||
this.app.notifications.success('Renamed');
|
this.app.notifications.success("Renamed");
|
||||||
await this._refresh(sessionId);
|
await this._refresh(sessionId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error('Rename failed: ' + (err.message || err));
|
this.app.notifications.error("Rename failed: " + (err.message || err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _delete(sessionId, entry) {
|
async _delete(sessionId, entry) {
|
||||||
try {
|
try {
|
||||||
await window.go.api.Service.SFTPRemove(sessionId, entry.path);
|
await window.go.api.Service.SFTPRemove(sessionId, entry.path);
|
||||||
this.app.notifications.success((entry.isDir ? 'Directory' : 'File') + ' deleted');
|
this.app.notifications.success(
|
||||||
|
(entry.isDir ? "Directory" : "File") + " deleted",
|
||||||
|
);
|
||||||
await this._refresh(sessionId);
|
await this._refresh(sessionId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.app.notifications.error('Delete failed: ' + (err.message || err));
|
this.app.notifications.error("Delete failed: " + (err.message || err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,10 +602,10 @@ class SFTPBrowser {
|
|||||||
|
|
||||||
_promptMkdir(sessionId) {
|
_promptMkdir(sessionId) {
|
||||||
this._showInputModal({
|
this._showInputModal({
|
||||||
title: 'New Folder',
|
title: "New Folder",
|
||||||
placeholder: 'Folder name',
|
placeholder: "Folder name",
|
||||||
confirmLabel: 'Create',
|
confirmLabel: "Create",
|
||||||
onConfirm: function(value) {
|
onConfirm: function (value) {
|
||||||
if (value.trim()) this._mkdir(sessionId, value.trim());
|
if (value.trim()) this._mkdir(sessionId, value.trim());
|
||||||
}.bind(this),
|
}.bind(this),
|
||||||
});
|
});
|
||||||
@@ -524,11 +613,11 @@ class SFTPBrowser {
|
|||||||
|
|
||||||
_promptRename(sessionId, entry) {
|
_promptRename(sessionId, entry) {
|
||||||
this._showInputModal({
|
this._showInputModal({
|
||||||
title: 'Rename',
|
title: "Rename",
|
||||||
placeholder: 'New name',
|
placeholder: "New name",
|
||||||
initialValue: entry.name,
|
initialValue: entry.name,
|
||||||
confirmLabel: 'Rename',
|
confirmLabel: "Rename",
|
||||||
onConfirm: function(value) {
|
onConfirm: function (value) {
|
||||||
var v = value.trim();
|
var v = value.trim();
|
||||||
if (v && v !== entry.name) this._rename(sessionId, entry.path, v);
|
if (v && v !== entry.name) this._rename(sessionId, entry.path, v);
|
||||||
}.bind(this),
|
}.bind(this),
|
||||||
@@ -537,11 +626,12 @@ class SFTPBrowser {
|
|||||||
|
|
||||||
_confirmDelete(sessionId, entry) {
|
_confirmDelete(sessionId, entry) {
|
||||||
this._showConfirmModal({
|
this._showConfirmModal({
|
||||||
title: 'Delete ' + (entry.isDir ? 'Folder' : 'File'),
|
title: "Delete " + (entry.isDir ? "Folder" : "File"),
|
||||||
message: 'Permanently delete <strong>' + escapeHtml(entry.name) + '</strong>?',
|
message:
|
||||||
confirmLabel: 'Delete',
|
"Permanently delete <strong>" + escapeHtml(entry.name) + "</strong>?",
|
||||||
|
confirmLabel: "Delete",
|
||||||
isDanger: true,
|
isDanger: true,
|
||||||
onConfirm: function() {
|
onConfirm: function () {
|
||||||
this._delete(sessionId, entry);
|
this._delete(sessionId, entry);
|
||||||
}.bind(this),
|
}.bind(this),
|
||||||
});
|
});
|
||||||
@@ -551,29 +641,38 @@ class SFTPBrowser {
|
|||||||
* Displays a modal with a text input field.
|
* Displays a modal with a text input field.
|
||||||
*/
|
*/
|
||||||
_showInputModal(opts) {
|
_showInputModal(opts) {
|
||||||
var backdrop = createElement('div', 'modal-backdrop');
|
var backdrop = createElement("div", "modal-backdrop");
|
||||||
var modal = createElement('div', 'modal modal-sm');
|
var modal = createElement("div", "modal modal-sm");
|
||||||
modal.innerHTML =
|
modal.innerHTML =
|
||||||
'<div class="modal-header">' +
|
'<div class="modal-header">' +
|
||||||
'<span class="modal-title">' + escapeHtml(opts.title) + '</span>' +
|
'<span class="modal-title">' +
|
||||||
|
escapeHtml(opts.title) +
|
||||||
|
"</span>" +
|
||||||
'<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' +
|
'<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' +
|
||||||
'</div>' +
|
"</div>" +
|
||||||
'<div class="modal-body">' +
|
'<div class="modal-body">' +
|
||||||
'<input class="sftp-inline-input" type="text" placeholder="' + escapeHtml(opts.placeholder || '') + '"' +
|
'<input class="sftp-inline-input" type="text" placeholder="' +
|
||||||
(opts.initialValue ? ' value="' + escapeHtml(opts.initialValue) + '"' : '') + ' />' +
|
escapeHtml(opts.placeholder || "") +
|
||||||
'</div>' +
|
'"' +
|
||||||
|
(opts.initialValue
|
||||||
|
? ' value="' + escapeHtml(opts.initialValue) + '"'
|
||||||
|
: "") +
|
||||||
|
" />" +
|
||||||
|
"</div>" +
|
||||||
'<div class="modal-footer">' +
|
'<div class="modal-footer">' +
|
||||||
'<button class="btn btn-sm btn-ghost sftp-modal-cancel">Cancel</button>' +
|
'<button class="btn btn-sm btn-ghost sftp-modal-cancel">Cancel</button>' +
|
||||||
'<button class="btn btn-sm btn-primary sftp-modal-confirm">' + escapeHtml(opts.confirmLabel || 'OK') + '</button>' +
|
'<button class="btn btn-sm btn-primary sftp-modal-confirm">' +
|
||||||
'</div>';
|
escapeHtml(opts.confirmLabel || "OK") +
|
||||||
|
"</button>" +
|
||||||
|
"</div>";
|
||||||
|
|
||||||
backdrop.appendChild(modal);
|
backdrop.appendChild(modal);
|
||||||
document.body.appendChild(backdrop);
|
document.body.appendChild(backdrop);
|
||||||
|
|
||||||
var input = modal.querySelector('.sftp-inline-input');
|
var input = modal.querySelector(".sftp-inline-input");
|
||||||
requestAnimationFrame(function() {
|
requestAnimationFrame(function () {
|
||||||
backdrop.classList.add('is-visible');
|
backdrop.classList.add("is-visible");
|
||||||
modal.classList.add('is-visible');
|
modal.classList.add("is-visible");
|
||||||
input.focus();
|
input.focus();
|
||||||
input.select();
|
input.select();
|
||||||
});
|
});
|
||||||
@@ -582,21 +681,37 @@ class SFTPBrowser {
|
|||||||
function close(confirmed) {
|
function close(confirmed) {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
closed = true;
|
closed = true;
|
||||||
backdrop.classList.remove('is-visible');
|
backdrop.classList.remove("is-visible");
|
||||||
modal.classList.remove('is-visible');
|
modal.classList.remove("is-visible");
|
||||||
setTimeout(function() { backdrop.remove(); }, 200);
|
setTimeout(function () {
|
||||||
|
backdrop.remove();
|
||||||
|
}, 200);
|
||||||
if (confirmed && opts.onConfirm) {
|
if (confirmed && opts.onConfirm) {
|
||||||
opts.onConfirm(input.value);
|
opts.onConfirm(input.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
modal.querySelector('.modal-close').addEventListener('click', function() { close(false); });
|
modal
|
||||||
modal.querySelector('.sftp-modal-cancel').addEventListener('click', function() { close(false); });
|
.querySelector(".modal-close")
|
||||||
modal.querySelector('.sftp-modal-confirm').addEventListener('click', function() { close(true); });
|
.addEventListener("click", function () {
|
||||||
backdrop.addEventListener('click', function(e) { if (e.target === backdrop) close(false); });
|
close(false);
|
||||||
input.addEventListener('keydown', function(e) {
|
});
|
||||||
if (e.key === 'Enter') close(true);
|
modal
|
||||||
if (e.key === 'Escape') close(false);
|
.querySelector(".sftp-modal-cancel")
|
||||||
|
.addEventListener("click", function () {
|
||||||
|
close(false);
|
||||||
|
});
|
||||||
|
modal
|
||||||
|
.querySelector(".sftp-modal-confirm")
|
||||||
|
.addEventListener("click", function () {
|
||||||
|
close(true);
|
||||||
|
});
|
||||||
|
backdrop.addEventListener("click", function (e) {
|
||||||
|
if (e.target === backdrop) close(false);
|
||||||
|
});
|
||||||
|
input.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Enter") close(true);
|
||||||
|
if (e.key === "Escape") close(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,47 +719,69 @@ class SFTPBrowser {
|
|||||||
* Displays a confirmation modal (no native confirm).
|
* Displays a confirmation modal (no native confirm).
|
||||||
*/
|
*/
|
||||||
_showConfirmModal(opts) {
|
_showConfirmModal(opts) {
|
||||||
var backdrop = createElement('div', 'modal-backdrop');
|
var backdrop = createElement("div", "modal-backdrop");
|
||||||
var modal = createElement('div', 'modal modal-sm confirm-dialog');
|
var modal = createElement("div", "modal modal-sm confirm-dialog");
|
||||||
modal.innerHTML =
|
modal.innerHTML =
|
||||||
'<div class="modal-header">' +
|
'<div class="modal-header">' +
|
||||||
'<span class="modal-title">' + escapeHtml(opts.title) + '</span>' +
|
'<span class="modal-title">' +
|
||||||
|
escapeHtml(opts.title) +
|
||||||
|
"</span>" +
|
||||||
'<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' +
|
'<button class="modal-close btn btn-icon btn-xs btn-ghost">✕</button>' +
|
||||||
'</div>' +
|
"</div>" +
|
||||||
'<div class="modal-body">' +
|
'<div class="modal-body">' +
|
||||||
'<p>' + (opts.message || '') + '</p>' +
|
"<p>" +
|
||||||
'</div>' +
|
(opts.message || "") +
|
||||||
|
"</p>" +
|
||||||
|
"</div>" +
|
||||||
'<div class="modal-footer">' +
|
'<div class="modal-footer">' +
|
||||||
'<button class="btn btn-sm btn-ghost sftp-modal-cancel">Cancel</button>' +
|
'<button class="btn btn-sm btn-ghost sftp-modal-cancel">Cancel</button>' +
|
||||||
'<button class="btn btn-sm ' + (opts.isDanger ? 'btn-danger-filled' : 'btn-primary') + ' sftp-modal-confirm">' +
|
'<button class="btn btn-sm ' +
|
||||||
escapeHtml(opts.confirmLabel || 'Confirm') +
|
(opts.isDanger ? "btn-danger-filled" : "btn-primary") +
|
||||||
'</button>' +
|
' sftp-modal-confirm">' +
|
||||||
'</div>';
|
escapeHtml(opts.confirmLabel || "Confirm") +
|
||||||
|
"</button>" +
|
||||||
|
"</div>";
|
||||||
|
|
||||||
backdrop.appendChild(modal);
|
backdrop.appendChild(modal);
|
||||||
document.body.appendChild(backdrop);
|
document.body.appendChild(backdrop);
|
||||||
|
|
||||||
requestAnimationFrame(function() {
|
requestAnimationFrame(function () {
|
||||||
backdrop.classList.add('is-visible');
|
backdrop.classList.add("is-visible");
|
||||||
modal.classList.add('is-visible');
|
modal.classList.add("is-visible");
|
||||||
});
|
});
|
||||||
|
|
||||||
var closed = false;
|
var closed = false;
|
||||||
function close(confirmed) {
|
function close(confirmed) {
|
||||||
if (closed) return;
|
if (closed) return;
|
||||||
closed = true;
|
closed = true;
|
||||||
backdrop.classList.remove('is-visible');
|
backdrop.classList.remove("is-visible");
|
||||||
modal.classList.remove('is-visible');
|
modal.classList.remove("is-visible");
|
||||||
setTimeout(function() { backdrop.remove(); }, 200);
|
setTimeout(function () {
|
||||||
|
backdrop.remove();
|
||||||
|
}, 200);
|
||||||
if (confirmed && opts.onConfirm) {
|
if (confirmed && opts.onConfirm) {
|
||||||
opts.onConfirm();
|
opts.onConfirm();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
modal.querySelector('.modal-close').addEventListener('click', function() { close(false); });
|
modal
|
||||||
modal.querySelector('.sftp-modal-cancel').addEventListener('click', function() { close(false); });
|
.querySelector(".modal-close")
|
||||||
modal.querySelector('.sftp-modal-confirm').addEventListener('click', function() { close(true); });
|
.addEventListener("click", function () {
|
||||||
backdrop.addEventListener('click', function(e) { if (e.target === backdrop) close(false); });
|
close(false);
|
||||||
|
});
|
||||||
|
modal
|
||||||
|
.querySelector(".sftp-modal-cancel")
|
||||||
|
.addEventListener("click", function () {
|
||||||
|
close(false);
|
||||||
|
});
|
||||||
|
modal
|
||||||
|
.querySelector(".sftp-modal-confirm")
|
||||||
|
.addEventListener("click", function () {
|
||||||
|
close(true);
|
||||||
|
});
|
||||||
|
backdrop.addEventListener("click", function (e) {
|
||||||
|
if (e.target === backdrop) close(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Resize handle ────────────────────────────────────────────── */
|
/* ── Resize handle ────────────────────────────────────────────── */
|
||||||
@@ -657,45 +794,53 @@ class SFTPBrowser {
|
|||||||
|
|
||||||
function onMouseMove(e) {
|
function onMouseMove(e) {
|
||||||
var delta = startX - e.clientX;
|
var delta = startX - e.clientX;
|
||||||
var newWidth = Math.max(180, Math.min(startWidth + delta, window.innerWidth * 0.5));
|
var newWidth = Math.max(
|
||||||
panel.style.width = newWidth + 'px';
|
180,
|
||||||
|
Math.min(startWidth + delta, window.innerWidth * 0.5),
|
||||||
|
);
|
||||||
|
panel.style.width = newWidth + "px";
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMouseUp() {
|
function onMouseUp() {
|
||||||
handle.classList.remove('is-dragging');
|
handle.classList.remove("is-dragging");
|
||||||
document.body.style.userSelect = '';
|
document.body.style.userSelect = "";
|
||||||
document.body.style.cursor = '';
|
document.body.style.cursor = "";
|
||||||
document.removeEventListener('mousemove', onMouseMove);
|
document.removeEventListener("mousemove", onMouseMove);
|
||||||
document.removeEventListener('mouseup', onMouseUp);
|
document.removeEventListener("mouseup", onMouseUp);
|
||||||
self._refitTerminal(sessionId);
|
self._refitTerminal(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
handle.addEventListener('mousedown', function(e) {
|
handle.addEventListener("mousedown", function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
startX = e.clientX;
|
startX = e.clientX;
|
||||||
startWidth = panel.offsetWidth;
|
startWidth = panel.offsetWidth;
|
||||||
handle.classList.add('is-dragging');
|
handle.classList.add("is-dragging");
|
||||||
document.body.style.userSelect = 'none';
|
document.body.style.userSelect = "none";
|
||||||
document.body.style.cursor = 'col-resize';
|
document.body.style.cursor = "col-resize";
|
||||||
document.addEventListener('mousemove', onMouseMove);
|
document.addEventListener("mousemove", onMouseMove);
|
||||||
document.addEventListener('mouseup', onMouseUp);
|
document.addEventListener("mouseup", onMouseUp);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Terminal refit ────────────────────────────────────────────── */
|
/* ── Terminal refit ────────────────────────────────────────────── */
|
||||||
|
|
||||||
_refitTerminal(sessionId) {
|
_refitTerminal(sessionId) {
|
||||||
if (this.app.terminals && typeof this.app.terminals.fitSession === 'function') {
|
if (
|
||||||
|
this.app.terminals &&
|
||||||
|
typeof this.app.terminals.fitSession === "function"
|
||||||
|
) {
|
||||||
// Small delay lets the layout settle before measuring
|
// Small delay lets the layout settle before measuring
|
||||||
setTimeout(function() {
|
setTimeout(
|
||||||
|
function () {
|
||||||
this.app.terminals.fitSession(sessionId);
|
this.app.terminals.fitSession(sessionId);
|
||||||
}.bind(this), 50);
|
}.bind(this),
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Export ────────────────────────────────────────────────────────── */
|
|
||||||
window.NexusTerminal = window.NexusTerminal || {};
|
|
||||||
window.NexusTerminal.SFTPBrowser = SFTPBrowser;
|
|
||||||
|
|
||||||
|
/* ── Export ────────────────────────────────────────────────────────── */
|
||||||
|
window.NexusTerminal = window.NexusTerminal || {};
|
||||||
|
window.NexusTerminal.SFTPBrowser = SFTPBrowser;
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -253,11 +253,22 @@
|
|||||||
const editBtn = createElement("button", "sidebar-item-edit", {
|
const editBtn = createElement("button", "sidebar-item-edit", {
|
||||||
textContent: "✎",
|
textContent: "✎",
|
||||||
title: "Edit profile",
|
title: "Edit profile",
|
||||||
|
type: "button",
|
||||||
|
"aria-label": "Edit profile",
|
||||||
});
|
});
|
||||||
editBtn.addEventListener("click", (e) => {
|
let editTriggered = false;
|
||||||
|
const triggerEdit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.app.profileEditor.openEdit(profile.id);
|
if (editTriggered) return;
|
||||||
|
editTriggered = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
editTriggered = false;
|
||||||
});
|
});
|
||||||
|
this.app.profileEditor.openEdit(profile.id);
|
||||||
|
};
|
||||||
|
editBtn.addEventListener("mousedown", triggerEdit);
|
||||||
|
editBtn.addEventListener("click", triggerEdit);
|
||||||
item.appendChild(editBtn);
|
item.appendChild(editBtn);
|
||||||
|
|
||||||
// Favourite star
|
// Favourite star
|
||||||
@@ -268,8 +279,11 @@
|
|||||||
item.appendChild(star);
|
item.appendChild(star);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent mousedown text selection in WKWebView
|
// Prevent mousedown text selection in WKWebView, but allow real controls.
|
||||||
item.addEventListener("mousedown", (e) => e.preventDefault());
|
item.addEventListener("mousedown", (e) => {
|
||||||
|
if (e.target.closest("button, input, select, textarea")) return;
|
||||||
|
e.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
// Click → connect / multi-select
|
// Click → connect / multi-select
|
||||||
item.addEventListener("click", (e) => {
|
item.addEventListener("click", (e) => {
|
||||||
|
|||||||
@@ -120,6 +120,14 @@ class TabManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnects a session by ID.
|
||||||
|
* @param {string} sessionId
|
||||||
|
*/
|
||||||
|
reconnectSession(sessionId) {
|
||||||
|
return this._reconnect(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disconnects and closes a tab by session ID.
|
* Disconnects and closes a tab by session ID.
|
||||||
* @param {string} sessionId
|
* @param {string} sessionId
|
||||||
|
|||||||
+142
-32
@@ -104,7 +104,10 @@
|
|||||||
|
|
||||||
// Drag-and-drop file handling
|
// Drag-and-drop file handling
|
||||||
container.addEventListener("dragover", (e) => {
|
container.addEventListener("dragover", (e) => {
|
||||||
if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes("Files")) {
|
if (
|
||||||
|
e.dataTransfer &&
|
||||||
|
Array.from(e.dataTransfer.types || []).includes("Files")
|
||||||
|
) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = "copy";
|
e.dataTransfer.dropEffect = "copy";
|
||||||
}
|
}
|
||||||
@@ -230,9 +233,7 @@
|
|||||||
webLinksAddon,
|
webLinksAddon,
|
||||||
observer,
|
observer,
|
||||||
decoder:
|
decoder:
|
||||||
typeof TextDecoder !== "undefined"
|
typeof TextDecoder !== "undefined" ? new TextDecoder("utf-8") : null,
|
||||||
? new TextDecoder("utf-8")
|
|
||||||
: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return term;
|
return term;
|
||||||
@@ -327,6 +328,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows or hides the disconnected overlay for a session panel.
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @param {boolean} isDisconnected
|
||||||
|
* @param {string} [error]
|
||||||
|
*/
|
||||||
|
setDisconnectedOverlay(sessionId, isDisconnected, error) {
|
||||||
|
const panel = document.getElementById(`panel-${sessionId}`);
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
const overlay = this._ensureDisconnectOverlay(panel, sessionId);
|
||||||
|
if (!overlay) return;
|
||||||
|
|
||||||
|
const message = overlay.querySelector(".terminal-disconnect-message");
|
||||||
|
if (message) {
|
||||||
|
message.textContent = error ? error : "Connection lost.";
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.classList.toggle("is-disconnected", isDisconnected);
|
||||||
|
overlay.classList.toggle("is-visible", isDisconnected);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Searches within a terminal using the search addon.
|
* Searches within a terminal using the search addon.
|
||||||
* @param {string} sessionId
|
* @param {string} sessionId
|
||||||
@@ -416,6 +439,44 @@
|
|||||||
|
|
||||||
// ── Private helpers ──────────────────────────────────────────────
|
// ── Private helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the disconnect overlay exists for a session panel.
|
||||||
|
* @param {HTMLElement} panel
|
||||||
|
* @param {string} sessionId
|
||||||
|
* @returns {HTMLElement|null}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_ensureDisconnectOverlay(panel, sessionId) {
|
||||||
|
let overlay = panel.querySelector(".terminal-disconnect-overlay");
|
||||||
|
if (overlay) return overlay;
|
||||||
|
|
||||||
|
const { createElement } = window.NexusTerminal;
|
||||||
|
if (!createElement) return null;
|
||||||
|
|
||||||
|
overlay = createElement("div", "terminal-disconnect-overlay");
|
||||||
|
const content = createElement("div", "terminal-disconnect-content");
|
||||||
|
const title = createElement("div", "terminal-disconnect-title", {
|
||||||
|
textContent: "DISCONNECTED",
|
||||||
|
});
|
||||||
|
const message = createElement("div", "terminal-disconnect-message", {
|
||||||
|
textContent: "Connection lost.",
|
||||||
|
});
|
||||||
|
const button = createElement("button", "btn btn-sm btn-primary", {
|
||||||
|
textContent: "Reconnect",
|
||||||
|
});
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
this.app.tabs.reconnectSession(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
content.appendChild(title);
|
||||||
|
content.appendChild(message);
|
||||||
|
content.appendChild(button);
|
||||||
|
overlay.appendChild(content);
|
||||||
|
panel.appendChild(overlay);
|
||||||
|
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shows a right-click context menu for a terminal.
|
* Shows a right-click context menu for a terminal.
|
||||||
* @param {string} sessionId
|
* @param {string} sessionId
|
||||||
@@ -540,7 +601,9 @@
|
|||||||
}
|
}
|
||||||
if (choice === "upload") {
|
if (choice === "upload") {
|
||||||
if (!canUpload) {
|
if (!canUpload) {
|
||||||
this.app.notifications.error("Upload is only available for SSH sessions.");
|
this.app.notifications.error(
|
||||||
|
"Upload is only available for SSH sessions.",
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._uploadDroppedFiles(sessionId, files);
|
this._uploadDroppedFiles(sessionId, files);
|
||||||
@@ -555,8 +618,12 @@
|
|||||||
const modal = createElement("div", "modal modal-sm");
|
const modal = createElement("div", "modal modal-sm");
|
||||||
const count = files.length;
|
const count = files.length;
|
||||||
const first = files[0];
|
const first = files[0];
|
||||||
const sizeLabel = first && typeof first.size === "number" ? formatBytes(first.size) : "";
|
const sizeLabel =
|
||||||
const title = count === 1 ? (first ? first.name : "File") : count + " files";
|
first && typeof first.size === "number"
|
||||||
|
? formatBytes(first.size)
|
||||||
|
: "";
|
||||||
|
const title =
|
||||||
|
count === 1 ? (first ? first.name : "File") : count + " files";
|
||||||
|
|
||||||
modal.innerHTML = `
|
modal.innerHTML = `
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
@@ -648,7 +715,9 @@
|
|||||||
if (!remoteDir) return;
|
if (!remoteDir) return;
|
||||||
|
|
||||||
if (files.length > 1) {
|
if (files.length > 1) {
|
||||||
this.app.notifications.info("Uploading " + files.length + " files...");
|
this.app.notifications.info(
|
||||||
|
"Uploading " + files.length + " files...",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i += 1) {
|
for (let i = 0; i < files.length; i += 1) {
|
||||||
@@ -664,7 +733,9 @@
|
|||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err && err.message ? err.message : String(err);
|
const msg = err && err.message ? err.message : String(err);
|
||||||
this.app.notifications.error("Upload failed for " + file.name + ": " + msg);
|
this.app.notifications.error(
|
||||||
|
"Upload failed for " + file.name + ": " + msg,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -738,7 +809,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listEl = modal.querySelector(".sftp-picker-list");
|
const listEl = modal.querySelector(".sftp-picker-list");
|
||||||
const breadcrumbEl = modal.querySelector("[data-role=\"breadcrumb\"]");
|
const breadcrumbEl = modal.querySelector('[data-role="breadcrumb"]');
|
||||||
const pathLabel = modal.querySelector(".sftp-picker-path");
|
const pathLabel = modal.querySelector(".sftp-picker-path");
|
||||||
const upBtn = modal.querySelector(".sftp-picker-up");
|
const upBtn = modal.querySelector(".sftp-picker-up");
|
||||||
const refreshBtn = modal.querySelector(".sftp-picker-refresh");
|
const refreshBtn = modal.querySelector(".sftp-picker-refresh");
|
||||||
@@ -771,7 +842,9 @@
|
|||||||
modal
|
modal
|
||||||
.querySelector(".prompt-cancel")
|
.querySelector(".prompt-cancel")
|
||||||
.addEventListener("click", () => close(null));
|
.addEventListener("click", () => close(null));
|
||||||
useBtn.addEventListener("click", () => close(state.selected || state.cwd));
|
useBtn.addEventListener("click", () =>
|
||||||
|
close(state.selected || state.cwd),
|
||||||
|
);
|
||||||
backdrop.addEventListener("click", (e) => {
|
backdrop.addEventListener("click", (e) => {
|
||||||
if (e.target === backdrop) close(null);
|
if (e.target === backdrop) close(null);
|
||||||
});
|
});
|
||||||
@@ -782,7 +855,9 @@
|
|||||||
for (let i = 0; i < segments.length; i += 1) {
|
for (let i = 0; i < segments.length; i += 1) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
breadcrumbEl.appendChild(
|
breadcrumbEl.appendChild(
|
||||||
createElement("span", "sftp-breadcrumb-sep", { textContent: ">" }),
|
createElement("span", "sftp-breadcrumb-sep", {
|
||||||
|
textContent: ">",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const seg = segments[i];
|
const seg = segments[i];
|
||||||
@@ -797,9 +872,13 @@
|
|||||||
|
|
||||||
const updateStatus = () => {
|
const updateStatus = () => {
|
||||||
if (state.selected) {
|
if (state.selected) {
|
||||||
statusLabel.textContent = state.selectedWritable ? "" : "No write permission";
|
statusLabel.textContent = state.selectedWritable
|
||||||
|
? ""
|
||||||
|
: "No write permission";
|
||||||
} else {
|
} else {
|
||||||
statusLabel.textContent = state.cwdWritable ? "" : "Current folder is read only";
|
statusLabel.textContent = state.cwdWritable
|
||||||
|
? ""
|
||||||
|
: "Current folder is read only";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -807,7 +886,9 @@
|
|||||||
listEl.querySelectorAll(".sftp-file-row").forEach((el) => {
|
listEl.querySelectorAll(".sftp-file-row").forEach((el) => {
|
||||||
el.classList.remove("is-selected");
|
el.classList.remove("is-selected");
|
||||||
});
|
});
|
||||||
const row = listEl.querySelector(`.sftp-file-row[data-index="${index}"]`);
|
const row = listEl.querySelector(
|
||||||
|
`.sftp-file-row[data-index="${index}"]`,
|
||||||
|
);
|
||||||
if (row) row.classList.add("is-selected");
|
if (row) row.classList.add("is-selected");
|
||||||
state.selected = entry.path;
|
state.selected = entry.path;
|
||||||
state.selectedWritable = entry.canWrite !== false;
|
state.selectedWritable = entry.canWrite !== false;
|
||||||
@@ -821,7 +902,11 @@
|
|||||||
const renderList = () => {
|
const renderList = () => {
|
||||||
listEl.innerHTML = "";
|
listEl.innerHTML = "";
|
||||||
const basePath = state.selected || state.cwd || "";
|
const basePath = state.selected || state.cwd || "";
|
||||||
const suffix = state.selected ? "" : (state.cwdWritable ? "" : " (read only)");
|
const suffix = state.selected
|
||||||
|
? ""
|
||||||
|
: state.cwdWritable
|
||||||
|
? ""
|
||||||
|
: " (read only)";
|
||||||
pathLabel.textContent = basePath + suffix;
|
pathLabel.textContent = basePath + suffix;
|
||||||
|
|
||||||
if (state.cwd && state.cwd !== "/") {
|
if (state.cwd && state.cwd !== "/") {
|
||||||
@@ -831,18 +916,23 @@
|
|||||||
path: this._parentPath(state.cwd),
|
path: this._parentPath(state.cwd),
|
||||||
});
|
});
|
||||||
parentRow.classList.add("is-parent");
|
parentRow.classList.add("is-parent");
|
||||||
parentRow.addEventListener("dblclick", () => loadDir(parentRow.dataset.path));
|
parentRow.addEventListener("dblclick", () =>
|
||||||
|
loadDir(parentRow.dataset.path),
|
||||||
|
);
|
||||||
listEl.appendChild(parentRow);
|
listEl.appendChild(parentRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = state.filter
|
const filtered = state.filter
|
||||||
? state.entries.filter((entry) => entry.name.toLowerCase().includes(state.filter))
|
? state.entries.filter((entry) =>
|
||||||
|
entry.name.toLowerCase().includes(state.filter),
|
||||||
|
)
|
||||||
: state.entries;
|
: state.entries;
|
||||||
state.visibleEntries = filtered;
|
state.visibleEntries = filtered;
|
||||||
|
|
||||||
if (!filtered.length) {
|
if (!filtered.length) {
|
||||||
const empty = createElement("div", "sftp-state");
|
const empty = createElement("div", "sftp-state");
|
||||||
empty.innerHTML = "<div class=\"sftp-state-icon\">D</div><div class=\"sftp-state-msg\">No folders found</div>";
|
empty.innerHTML =
|
||||||
|
'<div class="sftp-state-icon">D</div><div class="sftp-state-msg">No folders found</div>';
|
||||||
listEl.appendChild(empty);
|
listEl.appendChild(empty);
|
||||||
state.cursorIndex = -1;
|
state.cursorIndex = -1;
|
||||||
updateStatus();
|
updateStatus();
|
||||||
@@ -873,7 +963,8 @@
|
|||||||
const renderLoading = () => {
|
const renderLoading = () => {
|
||||||
listEl.innerHTML = "";
|
listEl.innerHTML = "";
|
||||||
const s = createElement("div", "sftp-state");
|
const s = createElement("div", "sftp-state");
|
||||||
s.innerHTML = "<div class=\"sftp-spinner\"></div><div class=\"sftp-state-msg\">Loading...</div>";
|
s.innerHTML =
|
||||||
|
'<div class="sftp-spinner"></div><div class="sftp-state-msg">Loading...</div>';
|
||||||
listEl.appendChild(s);
|
listEl.appendChild(s);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -881,11 +972,14 @@
|
|||||||
listEl.innerHTML = "";
|
listEl.innerHTML = "";
|
||||||
const s = createElement("div", "sftp-state");
|
const s = createElement("div", "sftp-state");
|
||||||
s.innerHTML =
|
s.innerHTML =
|
||||||
"<div class=\"sftp-state-icon\">!</div>" +
|
'<div class="sftp-state-icon">!</div>' +
|
||||||
"<div class=\"sftp-state-msg\">" + escapeHtml(msg) + "</div>" +
|
'<div class="sftp-state-msg">' +
|
||||||
"<button class=\"btn btn-sm btn-secondary sftp-state-retry\">Retry</button>";
|
escapeHtml(msg) +
|
||||||
|
"</div>" +
|
||||||
|
'<button class="btn btn-sm btn-secondary sftp-state-retry">Retry</button>';
|
||||||
const retryBtn = s.querySelector(".sftp-state-retry");
|
const retryBtn = s.querySelector(".sftp-state-retry");
|
||||||
if (retryBtn) retryBtn.addEventListener("click", () => loadDir(state.cwd));
|
if (retryBtn)
|
||||||
|
retryBtn.addEventListener("click", () => loadDir(state.cwd));
|
||||||
listEl.appendChild(s);
|
listEl.appendChild(s);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -894,15 +988,26 @@
|
|||||||
state.loading = true;
|
state.loading = true;
|
||||||
renderLoading();
|
renderLoading();
|
||||||
try {
|
try {
|
||||||
const targetPath = path || (await window.go.api.Service.SFTPPwd(sessionId));
|
const targetPath =
|
||||||
const files = await window.go.api.Service.SFTPList(sessionId, targetPath);
|
path || (await window.go.api.Service.SFTPPwd(sessionId));
|
||||||
const stat = await window.go.api.Service.SFTPStat(sessionId, targetPath);
|
const files = await window.go.api.Service.SFTPList(
|
||||||
|
sessionId,
|
||||||
|
targetPath,
|
||||||
|
);
|
||||||
|
const stat = await window.go.api.Service.SFTPStat(
|
||||||
|
sessionId,
|
||||||
|
targetPath,
|
||||||
|
);
|
||||||
state.cwd = targetPath;
|
state.cwd = targetPath;
|
||||||
state.selected = "";
|
state.selected = "";
|
||||||
state.selectedWritable = true;
|
state.selectedWritable = true;
|
||||||
state.cwdWritable = stat && this._modeCanWrite(stat.mode);
|
state.cwdWritable = stat && this._modeCanWrite(stat.mode);
|
||||||
state.entries = (files || []).filter((e) => e.isDir).sort((a, b) =>
|
state.entries = (files || [])
|
||||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
|
.filter((e) => e.isDir)
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.name.localeCompare(b.name, undefined, {
|
||||||
|
sensitivity: "base",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
state.entries = state.entries.map((entry) => {
|
state.entries = state.entries.map((entry) => {
|
||||||
const canRead = this._modeCanRead(entry.mode);
|
const canRead = this._modeCanRead(entry.mode);
|
||||||
@@ -972,7 +1077,8 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const initial = this.app.sftp && typeof this.app.sftp.getCwd === "function"
|
const initial =
|
||||||
|
this.app.sftp && typeof this.app.sftp.getCwd === "function"
|
||||||
? this.app.sftp.getCwd(sessionId)
|
? this.app.sftp.getCwd(sessionId)
|
||||||
: "";
|
: "";
|
||||||
loadDir(initial || "");
|
loadDir(initial || "");
|
||||||
@@ -986,7 +1092,9 @@
|
|||||||
dataset.index = String(index);
|
dataset.index = String(index);
|
||||||
}
|
}
|
||||||
const row = createElement("div", "sftp-file-row", { dataset });
|
const row = createElement("div", "sftp-file-row", { dataset });
|
||||||
const icon = createElement("span", "sftp-file-icon is-dir", { textContent: "DIR" });
|
const icon = createElement("span", "sftp-file-icon is-dir", {
|
||||||
|
textContent: "DIR",
|
||||||
|
});
|
||||||
const name = createElement("span", "sftp-file-name", {
|
const name = createElement("span", "sftp-file-name", {
|
||||||
textContent: entry.name || entry.path || "",
|
textContent: entry.name || entry.path || "",
|
||||||
title: entry.name || entry.path || "",
|
title: entry.name || entry.path || "",
|
||||||
@@ -997,7 +1105,9 @@
|
|||||||
} else if (entry.canWrite === false) {
|
} else if (entry.canWrite === false) {
|
||||||
metaLabel = "READ ONLY";
|
metaLabel = "READ ONLY";
|
||||||
}
|
}
|
||||||
const meta = createElement("span", "sftp-file-meta", { textContent: metaLabel });
|
const meta = createElement("span", "sftp-file-meta", {
|
||||||
|
textContent: metaLabel,
|
||||||
|
});
|
||||||
row.appendChild(icon);
|
row.appendChild(icon);
|
||||||
row.appendChild(name);
|
row.appendChild(name);
|
||||||
row.appendChild(meta);
|
row.appendChild(meta);
|
||||||
|
|||||||
Vendored
+12
@@ -41,6 +41,10 @@ export function ExportProfiles(arg1:boolean):Promise<string>;
|
|||||||
|
|
||||||
export function ExportProfilesEncrypted(arg1:Array<string>,arg2:string):Promise<string>;
|
export function ExportProfilesEncrypted(arg1:Array<string>,arg2:string):Promise<string>;
|
||||||
|
|
||||||
|
export function FileExists(arg1:string):Promise<boolean>;
|
||||||
|
|
||||||
|
export function FileIsPrivateKey(arg1:string):Promise<boolean>;
|
||||||
|
|
||||||
export function GetAppInfo():Promise<Record<string, string>>;
|
export function GetAppInfo():Promise<Record<string, string>>;
|
||||||
|
|
||||||
export function GetPreferences():Promise<models.AppPreferences>;
|
export function GetPreferences():Promise<models.AppPreferences>;
|
||||||
@@ -49,6 +53,8 @@ export function GetProfile(arg1:string):Promise<models.Profile>;
|
|||||||
|
|
||||||
export function GetRecording(arg1:string):Promise<Record<string, any>>;
|
export function GetRecording(arg1:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
|
export function GetSessionCwd(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function GetSessionHistory(arg1:string):Promise<Array<Record<string, any>>>;
|
export function GetSessionHistory(arg1:string):Promise<Array<Record<string, any>>>;
|
||||||
|
|
||||||
export function GetTOTPCode(arg1:string):Promise<string>;
|
export function GetTOTPCode(arg1:string):Promise<string>;
|
||||||
@@ -87,6 +93,8 @@ export function ListProfilesByTag(arg1:string):Promise<Array<models.Profile>>;
|
|||||||
|
|
||||||
export function ListRecordings():Promise<Array<Record<string, any>>>;
|
export function ListRecordings():Promise<Array<Record<string, any>>>;
|
||||||
|
|
||||||
|
export function ListSSHKeys():Promise<Array<string>>;
|
||||||
|
|
||||||
export function ListSessions():Promise<Array<models.SessionInfo>>;
|
export function ListSessions():Promise<Array<models.SessionInfo>>;
|
||||||
|
|
||||||
export function ListSnippets():Promise<Array<models.Snippet>>;
|
export function ListSnippets():Promise<Array<models.Snippet>>;
|
||||||
@@ -119,8 +127,12 @@ export function SFTPRemove(arg1:string,arg2:string):Promise<void>;
|
|||||||
|
|
||||||
export function SFTPRename(arg1:string,arg2:string,arg3:string):Promise<void>;
|
export function SFTPRename(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SFTPStat(arg1:string,arg2:string):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function SFTPUpload(arg1:string,arg2:string):Promise<void>;
|
export function SFTPUpload(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SFTPUploadData(arg1:string,arg2:string,arg3:string,arg4:string):Promise<void>;
|
||||||
|
|
||||||
export function SaveFile(arg1:string,arg2:string):Promise<string>;
|
export function SaveFile(arg1:string,arg2:string):Promise<string>;
|
||||||
|
|
||||||
export function SavePreferences(arg1:models.AppPreferences):Promise<void>;
|
export function SavePreferences(arg1:models.AppPreferences):Promise<void>;
|
||||||
|
|||||||
@@ -78,6 +78,14 @@ export function ExportProfilesEncrypted(arg1, arg2) {
|
|||||||
return window['go']['api']['Service']['ExportProfilesEncrypted'](arg1, arg2);
|
return window['go']['api']['Service']['ExportProfilesEncrypted'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function FileExists(arg1) {
|
||||||
|
return window['go']['api']['Service']['FileExists'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileIsPrivateKey(arg1) {
|
||||||
|
return window['go']['api']['Service']['FileIsPrivateKey'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetAppInfo() {
|
export function GetAppInfo() {
|
||||||
return window['go']['api']['Service']['GetAppInfo']();
|
return window['go']['api']['Service']['GetAppInfo']();
|
||||||
}
|
}
|
||||||
@@ -94,6 +102,10 @@ export function GetRecording(arg1) {
|
|||||||
return window['go']['api']['Service']['GetRecording'](arg1);
|
return window['go']['api']['Service']['GetRecording'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetSessionCwd(arg1) {
|
||||||
|
return window['go']['api']['Service']['GetSessionCwd'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetSessionHistory(arg1) {
|
export function GetSessionHistory(arg1) {
|
||||||
return window['go']['api']['Service']['GetSessionHistory'](arg1);
|
return window['go']['api']['Service']['GetSessionHistory'](arg1);
|
||||||
}
|
}
|
||||||
@@ -170,6 +182,10 @@ export function ListRecordings() {
|
|||||||
return window['go']['api']['Service']['ListRecordings']();
|
return window['go']['api']['Service']['ListRecordings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListSSHKeys() {
|
||||||
|
return window['go']['api']['Service']['ListSSHKeys']();
|
||||||
|
}
|
||||||
|
|
||||||
export function ListSessions() {
|
export function ListSessions() {
|
||||||
return window['go']['api']['Service']['ListSessions']();
|
return window['go']['api']['Service']['ListSessions']();
|
||||||
}
|
}
|
||||||
@@ -234,10 +250,18 @@ export function SFTPRename(arg1, arg2, arg3) {
|
|||||||
return window['go']['api']['Service']['SFTPRename'](arg1, arg2, arg3);
|
return window['go']['api']['Service']['SFTPRename'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SFTPStat(arg1, arg2) {
|
||||||
|
return window['go']['api']['Service']['SFTPStat'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function SFTPUpload(arg1, arg2) {
|
export function SFTPUpload(arg1, arg2) {
|
||||||
return window['go']['api']['Service']['SFTPUpload'](arg1, arg2);
|
return window['go']['api']['Service']['SFTPUpload'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SFTPUploadData(arg1, arg2, arg3, arg4) {
|
||||||
|
return window['go']['api']['Service']['SFTPUploadData'](arg1, arg2, arg3, arg4);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveFile(arg1, arg2) {
|
export function SaveFile(arg1, arg2) {
|
||||||
return window['go']['api']['Service']['SaveFile'](arg1, arg2);
|
return window['go']['api']['Service']['SaveFile'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,14 +3,17 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -145,6 +148,99 @@ func (s *Service) ListProfiles() ([]models.Profile, error) {
|
|||||||
return derefProfiles(profiles), nil
|
return derefProfiles(profiles), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListSSHKeys returns absolute paths to candidate SSH private keys in ~/.ssh.
|
||||||
|
func (s *Service) ListSSHKeys() ([]string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join(home, ".ssh")
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ignored := map[string]bool{
|
||||||
|
"authorized_keys": true,
|
||||||
|
"authorized_keys2": true,
|
||||||
|
"config": true,
|
||||||
|
"known_hosts": true,
|
||||||
|
"known_hosts.old": true,
|
||||||
|
"ssh_config": true,
|
||||||
|
"ssh_known_hosts": true,
|
||||||
|
"ssh_known_hosts2": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := make([]string, 0)
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
if ignored[name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(name, ".pub") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(name, ".old") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fullPath := filepath.Join(dir, name)
|
||||||
|
if !isLikelyPrivateKey(fullPath) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keys = append(keys, fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isLikelyPrivateKey(path string) bool {
|
||||||
|
file, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
buf := make([]byte, 512)
|
||||||
|
n, err := file.Read(buf)
|
||||||
|
if err != nil && n == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
head := buf[:n]
|
||||||
|
|
||||||
|
if bytes.HasPrefix(head, []byte("openssh-key-v1")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.Contains(head, []byte("BEGIN OPENSSH PRIVATE KEY")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if bytes.Contains(head, []byte("BEGIN RSA PRIVATE KEY")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if bytes.Contains(head, []byte("BEGIN DSA PRIVATE KEY")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if bytes.Contains(head, []byte("BEGIN EC PRIVATE KEY")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if bytes.Contains(head, []byte("BEGIN ED25519 PRIVATE KEY")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if bytes.Contains(head, []byte("BEGIN PRIVATE KEY")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// SearchProfiles searches profiles by display name, host, or tags.
|
// SearchProfiles searches profiles by display name, host, or tags.
|
||||||
func (s *Service) SearchProfiles(query string) ([]models.Profile, error) {
|
func (s *Service) SearchProfiles(query string) ([]models.Profile, error) {
|
||||||
profiles, err := s.store.SearchProfiles(query)
|
profiles, err := s.store.SearchProfiles(query)
|
||||||
@@ -733,6 +829,29 @@ func (s *Service) SaveFile(title string, defaultName string) (string, error) {
|
|||||||
return path, nil
|
return path, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FileExists returns true if the path exists and is not a directory.
|
||||||
|
func (s *Service) FileExists(path string) (bool, error) {
|
||||||
|
if path == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return !info.IsDir(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileIsPrivateKey returns true if the path appears to be an SSH private key.
|
||||||
|
func (s *Service) FileIsPrivateKey(path string) (bool, error) {
|
||||||
|
if path == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return isLikelyPrivateKey(path), nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetAppInfo returns build and environment information.
|
// GetAppInfo returns build and environment information.
|
||||||
func (s *Service) GetAppInfo() map[string]string {
|
func (s *Service) GetAppInfo() map[string]string {
|
||||||
return map[string]string{
|
return map[string]string{
|
||||||
|
|||||||
Reference in New Issue
Block a user