tabby-sftp-ui 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -237,6 +237,7 @@ __webpack_require__.r(__webpack_exports__);
237
237
  /* harmony import */ var tabby_core__WEBPACK_IMPORTED_MODULE_6___default = /*#__PURE__*/__webpack_require__.n(tabby_core__WEBPACK_IMPORTED_MODULE_6__);
238
238
  /* harmony import */ var _local_transfers__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./local-transfers */ "./src/local-transfers.ts");
239
239
  /* harmony import */ var _sftp_service__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ./sftp.service */ "./src/sftp.service.ts");
240
+ /* harmony import */ var _sftp_recovery_provider__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(/*! ./sftp-recovery-provider */ "./src/sftp-recovery-provider.ts");
240
241
  var __decorate = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
241
242
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
242
243
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -255,6 +256,7 @@ var __metadata = (undefined && undefined.__metadata) || function (k, v) {
255
256
 
256
257
 
257
258
 
259
+
258
260
  let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__WEBPACK_IMPORTED_MODULE_6__.BaseTabComponent {
259
261
  constructor(injector, sftp, profilesService, app) {
260
262
  // Tabby runtime BaseTabComponent expects Injector in constructor, but typings in this SDK may differ.
@@ -266,6 +268,7 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
266
268
  // injected from the SSH tab when opened via SFTP-UI button
267
269
  this.sshSession = null;
268
270
  this.profile = null;
271
+ this.recoveredStub = false;
269
272
  this.connecting = false;
270
273
  this.connected = false;
271
274
  // legacy UI fields kept for now (not used when opened from SSH tab)
@@ -353,9 +356,9 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
353
356
  // If there's no live SSH session, this tab was likely restored across
354
357
  // restart or opened in an invalid context. Close it immediately to avoid
355
358
  // an empty, nameless SFTP tab lingering after restart.
356
- if (!this.sshSession) {
359
+ if (this.recoveredStub || !this.sshSession) {
357
360
  try {
358
- this.app.closeTab(this);
361
+ this.app.closeTab(this.topmostParent ?? this);
359
362
  }
360
363
  catch (e) {
361
364
  console.error('[SFTP-UI] Failed to close invalid SFTP tab', e);
@@ -596,12 +599,13 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
596
599
  const sources = this.selectedLocal.includes(e) && this.selectedLocal.length ? this.selectedLocal : [e];
597
600
  const movePayload = sources.map(x => x.fullPath);
598
601
  ev.dataTransfer?.setData('application/x-tabby-sftp-ui-local-move', JSON.stringify(movePayload));
599
- // Existing cross-device drag (local -> remote) only for files
600
- if (!e.isDirectory) {
601
- const payload = { kind: 'local-file', fullPath: e.fullPath, name: e.name };
602
- ev.dataTransfer?.setData('application/x-tabby-sftp-ui', JSON.stringify(payload));
603
- ev.dataTransfer?.setData('text/plain', e.fullPath);
604
- }
602
+ // Cross-device drag (local -> remote) supports multi-select
603
+ const payload = {
604
+ kind: 'local-paths',
605
+ paths: sources.map(x => ({ fullPath: x.fullPath, name: x.name, isDirectory: x.isDirectory })),
606
+ };
607
+ ev.dataTransfer?.setData('application/x-tabby-sftp-ui', JSON.stringify(payload));
608
+ ev.dataTransfer?.setData('text/plain', e.fullPath);
605
609
  ev.dataTransfer?.setDragImage?.(ev.target ?? document.body, 0, 0);
606
610
  }
607
611
  onDragStartRemote(ev, item) {
@@ -611,10 +615,17 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
611
615
  const sources = this.selectedRemote.includes(item) && this.selectedRemote.length ? this.selectedRemote : [item];
612
616
  const movePayload = sources.map(x => x.fullPath);
613
617
  ev.dataTransfer?.setData('application/x-tabby-sftp-ui-remote-move', JSON.stringify(movePayload));
614
- // Existing cross-device drag (remote -> local) only for files
615
- const payload = item.isDirectory
616
- ? { kind: 'remote-dir', remotePath: item.fullPath, name: item.name }
617
- : { kind: 'remote-file', remotePath: item.fullPath, name: item.name, size: item.size, mode: item.mode };
618
+ // Cross-device drag (remote -> local) supports multi-select
619
+ const payload = {
620
+ kind: 'remote-paths',
621
+ paths: sources.map(x => ({
622
+ remotePath: x.fullPath,
623
+ name: x.name,
624
+ isDirectory: x.isDirectory,
625
+ size: x.size,
626
+ mode: x.mode,
627
+ })),
628
+ };
618
629
  ev.dataTransfer?.setData('application/x-tabby-sftp-ui', JSON.stringify(payload));
619
630
  ev.dataTransfer?.setData('text/plain', item.fullPath);
620
631
  ev.dataTransfer?.setDragImage?.(ev.target ?? document.body, 0, 0);
@@ -675,23 +686,40 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
675
686
  catch {
676
687
  return;
677
688
  }
678
- if (payload.kind !== 'local-file') {
679
- return;
680
- }
681
689
  try {
682
- const targetRemotePath = path__WEBPACK_IMPORTED_MODULE_0__.posix.join(this.remotePath, payload.name);
683
- const existsOnRemote = this.remoteEntries.some(e => e.name === payload.name);
684
- if (existsOnRemote) {
685
- const ok = await this.showReplaceConfirm(`Replace existing "${payload.name}" on remote?`);
686
- if (!ok) {
687
- return;
690
+ if (payload.kind === 'local-file') {
691
+ const targetRemotePath = path__WEBPACK_IMPORTED_MODULE_0__.posix.join(this.remotePath, payload.name);
692
+ const existsOnRemote = this.remoteEntries.some(e => e.name === payload.name);
693
+ if (existsOnRemote) {
694
+ const ok = await this.showReplaceConfirm(`Replace existing "${payload.name}" on remote?`);
695
+ if (!ok) {
696
+ return;
697
+ }
698
+ await this.deleteRemotePathRecursive(targetRemotePath);
688
699
  }
689
- await this.deleteRemotePathRecursive(targetRemotePath);
700
+ const upload = new _local_transfers__WEBPACK_IMPORTED_MODULE_7__.LocalPathFileUpload(payload.fullPath);
701
+ this.trackTransfer(upload, 'upload', targetRemotePath, payload.fullPath);
702
+ await this.sftpSession.upload(targetRemotePath, upload);
703
+ await this.refreshRemote();
704
+ return;
705
+ }
706
+ if (payload.kind === 'local-paths') {
707
+ for (const p of payload.paths) {
708
+ const targetRemotePath = path__WEBPACK_IMPORTED_MODULE_0__.posix.join(this.remotePath, p.name);
709
+ const existsOnRemote = this.remoteEntries.some(e => e.name === p.name);
710
+ if (existsOnRemote) {
711
+ const ok = await this.showReplaceConfirm(`Replace existing "${p.name}" on remote?`);
712
+ if (!ok) {
713
+ continue;
714
+ }
715
+ await this.deleteRemotePathRecursive(targetRemotePath);
716
+ }
717
+ // uploadLocalPathToRemote handles both files and directories
718
+ await this.uploadLocalPathToRemote(this.remotePath, p.fullPath);
719
+ }
720
+ await this.refreshRemote();
721
+ return;
690
722
  }
691
- const upload = new _local_transfers__WEBPACK_IMPORTED_MODULE_7__.LocalPathFileUpload(payload.fullPath);
692
- this.trackTransfer(upload, 'upload', targetRemotePath, payload.fullPath);
693
- await this.sftpSession.upload(targetRemotePath, upload);
694
- await this.refreshRemote();
695
723
  }
696
724
  catch (e) {
697
725
  console.error('[SFTP-UI] Upload failed', e);
@@ -753,6 +781,36 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
753
781
  }
754
782
  return;
755
783
  }
784
+ if (payload && payload.kind === 'remote-paths') {
785
+ try {
786
+ if (!this.sftpSession) {
787
+ throw new Error('Not connected');
788
+ }
789
+ for (const it of payload.paths) {
790
+ const targetLocalPath = path__WEBPACK_IMPORTED_MODULE_0__.join(this.localPath, it.name);
791
+ if (fs__WEBPACK_IMPORTED_MODULE_3__.existsSync(targetLocalPath)) {
792
+ const ok = await this.showReplaceConfirm(it.isDirectory ? `Replace existing folder "${it.name}"?` : `Replace existing "${it.name}"?`);
793
+ if (!ok) {
794
+ continue;
795
+ }
796
+ await this.deleteLocalPathRecursive(targetLocalPath);
797
+ }
798
+ if (it.isDirectory) {
799
+ await this.downloadRemoteDirectoryRecursive(it.remotePath, targetLocalPath);
800
+ }
801
+ else {
802
+ const dl = new _local_transfers__WEBPACK_IMPORTED_MODULE_7__.LocalPathFileDownload(targetLocalPath, it.mode ?? 0o644, it.size ?? 0);
803
+ this.trackTransfer(dl, 'download', it.remotePath, targetLocalPath);
804
+ await this.sftpSession.download(it.remotePath, dl);
805
+ }
806
+ }
807
+ await this.refreshLocal();
808
+ }
809
+ catch (e) {
810
+ console.error('[SFTP-UI] Download paths failed', e);
811
+ }
812
+ return;
813
+ }
756
814
  }
757
815
  // Drag & drop from OS file manager into the local pane (copy into current local folder)
758
816
  const osPaths = this.getDroppedOsPaths(ev);
@@ -967,7 +1025,8 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
967
1025
  const isWin = os__WEBPACK_IMPORTED_MODULE_1__.platform() === 'win32';
968
1026
  const isLocalPath = (p) => {
969
1027
  if (isWin) {
970
- return /^[A-Za-z]:\\/.test(p) || p.startsWith('\\\\');
1028
+ // Accept both `C:\...` and `C:/...` (some drag sources provide forward slashes)
1029
+ return /^[A-Za-z]:[\\/]/.test(p) || p.startsWith('\\\\');
971
1030
  }
972
1031
  return p.startsWith('/');
973
1032
  };
@@ -994,6 +1053,13 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
994
1053
  }
995
1054
  }
996
1055
  return x;
1056
+ })
1057
+ .map(x => {
1058
+ // Some sources may produce `/C:/Users/...`
1059
+ if (isWin && /^\/[A-Za-z]:[\\/]/.test(x)) {
1060
+ return x.slice(1);
1061
+ }
1062
+ return x;
997
1063
  })
998
1064
  .filter(x => x && isLocalPath(x));
999
1065
  if (uris.length) {
@@ -1013,6 +1079,12 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
1013
1079
  }
1014
1080
  }
1015
1081
  return x;
1082
+ })
1083
+ .map(x => {
1084
+ if (isWin && /^\/[A-Za-z]:[\\/]/.test(x)) {
1085
+ return x.slice(1);
1086
+ }
1087
+ return x;
1016
1088
  })
1017
1089
  .filter(x => x && isLocalPath(x));
1018
1090
  return textPaths;
@@ -1522,7 +1594,7 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
1522
1594
  getDefaultRemotePath() {
1523
1595
  const username = (this.profile && (this.profile.options?.username || this.profile.options?.user)) || '';
1524
1596
  if (username) {
1525
- return `/home/${username}`;
1597
+ return username === 'root' ? `/root` : `/home/${username}`;
1526
1598
  }
1527
1599
  return '/';
1528
1600
  }
@@ -1967,12 +2039,16 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
1967
2039
  }
1968
2040
  super.destroy();
1969
2041
  }
1970
- // Prevent Tabby from restoring SFTP-UI tabs across restarts, since they rely
1971
- // on a live SSH session from a terminal tab.
1972
- // Typинги допускают RecoveryToken | null, нам достаточно всегда возвращать null.
2042
+ // A custom recovery token ensures that a SplitTab wrapper won't become empty
2043
+ // after restart. On recovery we restore a stub SFTP tab which immediately
2044
+ // closes its `topmostParent` (the SplitTab wrapper).
1973
2045
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
1974
2046
  async getRecoveryToken(_options) {
1975
- return null;
2047
+ return {
2048
+ type: _sftp_recovery_provider__WEBPACK_IMPORTED_MODULE_9__.SFTP_RECOVERY_TYPE,
2049
+ version: 1,
2050
+ state: {},
2051
+ };
1976
2052
  }
1977
2053
  async confirmDelete() {
1978
2054
  if (!this.deleteConfirmVisible) {
@@ -2270,388 +2346,388 @@ __decorate([
2270
2346
  SftpManagerTabComponent = __decorate([
2271
2347
  (0,_angular_core__WEBPACK_IMPORTED_MODULE_5__.Component)({
2272
2348
  selector: 'tabby-sftp-manager-tab',
2273
- template: `
2274
- <div class="sftp-root" tabindex="0" (keydown)="onKeyDown($event)">
2275
- <div class="top-profiles" *ngIf="profile || recentProfiles.length">
2276
- <div class="current" *ngIf="profile">
2277
- <span class="label">Device:</span>
2278
- <span class="value">{{ getProfileLabel(profile) }}</span>
2279
- </div>
2280
- <div class="recent" *ngIf="recentProfiles.length">
2281
- <span class="label">Recent:</span>
2282
- <button
2283
- class="profile-chip"
2284
- *ngFor="let p of recentProfiles"
2285
- (click)="launchProfileFromSFTP(p)"
2286
- >
2287
- {{ getProfileLabel(p) }}
2288
- </button>
2289
- </div>
2290
- </div>
2291
- <div class="sftp-body">
2292
- <div class="pane">
2293
- <div class="pane-title">
2294
- <div class="pane-label">Local</div>
2295
- <div class="pane-path">
2296
- <input
2297
- [(ngModel)]="localPathInput"
2298
- (keyup.enter)="goToLocalPathInput()"
2299
- />
2300
- </div>
2301
- <div class="pane-actions">
2302
- <select class="path-preset" (change)="onLocalPresetChange($event.target.value)">
2303
- <option value="">Go to…</option>
2304
- <option *ngFor="let p of localPathPresets" [value]="p.id">
2305
- {{ p.label }}
2306
- </option>
2307
- </select>
2308
- <button
2309
- class="fav-toggle"
2310
- [class.active]="isCurrentFavorite()"
2311
- (click)="toggleCurrentFavorite()"
2312
- title="Toggle favorite for this path"
2313
- >
2314
-
2315
- </button>
2316
- <select class="path-favorite" (change)="onLocalFavoriteSelect($event.target.value)">
2317
- <option value="">Favorites…</option>
2318
- <option *ngFor="let f of localFavorites" [value]="f.id">
2319
- {{ f.label }}
2320
- </option>
2321
- </select>
2322
- <button (click)="localUp()" [disabled]="!canLocalUp()">Up</button>
2323
- <button (click)="goToLocalPathInput()">Go</button>
2324
- <button (click)="refreshLocal()">Refresh</button>
2325
- </div>
2326
- </div>
2327
- <div class="pane-filters">
2328
- <div class="breadcrumbs">
2329
- <ng-container *ngFor="let part of getLocalBreadcrumbs(); let i = index; let last = last">
2330
- <button
2331
- class="crumb-button"
2332
- (click)="navigateLocalBreadcrumb(i)"
2333
- (contextmenu)="onLocalBreadcrumbContextMenu(i, $event)"
2334
- >
2335
- {{ part.label }}
2336
- </button>
2337
- <span class="crumb-separator" *ngIf="!last">›</span>
2338
- </ng-container>
2339
- </div>
2340
- <input [(ngModel)]="localFilter" placeholder="Filter files..." />
2341
- <label class="show-hidden-toggle">
2342
- <input type="checkbox" [(ngModel)]="showHiddenLocal" />
2343
- <span>Show hidden</span>
2344
- </label>
2345
- </div>
2346
- <div class="pane-list"
2347
- (dragover)="onDragOver($event)"
2348
- (drop)="onDropOnLocal($event)"
2349
- >
2350
- <div class="entry header">
2351
- <span class="icon"></span>
2352
- <span class="name sortable" (click)="setLocalSort('name')">Name</span>
2353
- <span class="size sortable" (click)="setLocalSort('size')">Size</span>
2354
- <span class="date sortable" (click)="setLocalSort('modified')">Modified</span>
2355
- </div>
2356
- <div
2357
- class="entry"
2358
- *ngIf="canLocalUp()"
2359
- (dblclick)="localUp()"
2360
- >
2361
- <span class="icon">⬆</span>
2362
- <span class="name">Go up</span>
2363
- <span class="size"></span>
2364
- <span class="date"></span>
2365
- </div>
2366
- <div
2367
- class="entry"
2368
- *ngFor="let e of getFilteredLocalEntries()"
2369
- (click)="selectLocal(e, $event)"
2370
- (dblclick)="openLocal(e)"
2371
- (mousedown)="onLocalMouseDown(e, $event)"
2372
- (contextmenu)="onLocalContextMenu(e, $event)"
2373
- (dragover)="onLocalEntryDragOver(e, $event)"
2374
- (drop)="onLocalEntryDrop(e, $event)"
2375
- [class.drop-target]="localDropActive"
2376
- [class.selected]="isLocalSelected(e)"
2377
- [draggable]="true"
2378
- (dragstart)="onDragStartLocal($event, e)"
2379
- >
2380
- <span class="icon">{{ e.isDirectory ? '📁' : '📄' }}</span>
2381
- <span class="name">{{ e.name }}</span>
2382
- <span class="size">{{ getLocalSizeDisplay(e) }}</span>
2383
- <span class="date">{{ e.mtimeMs ? (e.mtimeMs | date:'yyyy-MM-dd HH:mm') : '' }}</span>
2384
- </div>
2385
- </div>
2386
- <div class="pane-actions-bar">
2387
- <div class="selection" *ngIf="selectedLocal.length">
2388
- Selected: {{ selectedLocal.length === 1 ? selectedLocal[0].name : (selectedLocal.length + ' items') }}
2389
- </div>
2390
- <div class="action-inputs">
2391
- <input [(ngModel)]="localActionName" placeholder="Name / new name" />
2392
- <input [(ngModel)]="localActionPerms" placeholder="Perms (e.g. 755)" />
2393
- </div>
2394
- <div class="action-buttons">
2395
- <button (click)="localRename()" [disabled]="selectedLocal.length !== 1">Rename</button>
2396
- <button (click)="refreshLocal()">Refresh</button>
2397
- <button (click)="localDelete()" [disabled]="!selectedLocal.length">Delete</button>
2398
- <button (click)="localNewFolder()">New Folder</button>
2399
- <button (click)="localEditPermissions()" [disabled]="selectedLocal.length !== 1 || !localActionPerms">Edit Permissions</button>
2400
- <button (click)="localShowSize()" [disabled]="selectedLocal.length !== 1 || !selectedLocal[0].isDirectory">Show Size</button>
2401
- </div>
2402
- </div>
2403
- </div>
2404
-
2405
- <div class="pane">
2406
- <div class="pane-title">
2407
- <div class="pane-label">
2408
- Remote
2409
- <span *ngIf="connected && profile?.options?.host" class="pane-sub">
2410
- — {{ profile.options.host }}
2411
- </span>
2412
- </div>
2413
- <div class="pane-path">
2414
- <input
2415
- [(ngModel)]="remotePathInput"
2416
- (keyup.enter)="goToRemotePathInput()"
2417
- [disabled]="!connected"
2418
- />
2419
- </div>
2420
- <div class="pane-actions">
2421
- <button (click)="remoteUp()" [disabled]="!connected || remotePath === '/'">Up</button>
2422
- <button (click)="goToRemotePathInput()" [disabled]="!connected">Go</button>
2423
- <button (click)="refreshRemote()" [disabled]="!connected">Refresh</button>
2424
- </div>
2425
- </div>
2426
- <div class="pane-filters">
2427
- <div class="breadcrumbs" *ngIf="connected">
2428
- <ng-container *ngFor="let part of getRemoteBreadcrumbs(); let i = index; let last = last">
2429
- <button
2430
- class="crumb-button"
2431
- (click)="navigateRemoteBreadcrumb(i)"
2432
- >
2433
- {{ part.label }}
2434
- </button>
2435
- <span class="crumb-separator" *ngIf="!last">›</span>
2436
- </ng-container>
2437
- </div>
2438
- <input [(ngModel)]="remoteFilter" placeholder="Filter files..." />
2439
- <label class="show-hidden-toggle">
2440
- <input type="checkbox" [(ngModel)]="showHiddenRemote" />
2441
- <span>Show hidden</span>
2442
- </label>
2443
- </div>
2444
- <div class="pane-list"
2445
- (dragover)="onDragOver($event)"
2446
- (drop)="onDropOnRemote($event)"
2447
- >
2448
- <div class="entry dim" *ngIf="!connected">
2449
- <span class="name">Not connected</span>
2450
- </div>
2451
- <div class="entry header" *ngIf="connected">
2452
- <span class="icon"></span>
2453
- <span class="name sortable" (click)="setRemoteSort('name')">Name</span>
2454
- <span class="size sortable" (click)="setRemoteSort('size')">Size</span>
2455
- <span class="date sortable" (click)="setRemoteSort('modified')">Modified</span>
2456
- </div>
2457
- <div
2458
- class="entry"
2459
- *ngIf="connected && remotePath !== '/'"
2460
- (dblclick)="remoteUp()"
2461
- >
2462
- <span class="icon">⬆</span>
2463
- <span class="name">Go up</span>
2464
- <span class="size"></span>
2465
- <span class="date"></span>
2466
- </div>
2467
- <div
2468
- class="entry"
2469
- *ngFor="let e of getFilteredRemoteEntries()"
2470
- (click)="selectRemote(e, $event)"
2471
- (dblclick)="openRemote(e)"
2472
- (mousedown)="onRemoteMouseDown(e, $event)"
2473
- (contextmenu)="onRemoteContextMenu(e, $event)"
2474
- (dragover)="onRemoteEntryDragOver(e, $event)"
2475
- (drop)="onRemoteEntryDrop(e, $event)"
2476
- [class.drop-target]="remoteDropActive"
2477
- [class.selected]="isRemoteSelected(e)"
2478
- [draggable]="connected"
2479
- (dragstart)="onDragStartRemote($event, e)"
2480
- >
2481
- <span class="icon">{{ e.isDirectory ? '📁' : '📄' }}</span>
2482
- <span class="name">{{ e.name }}</span>
2483
- <span class="size">{{ getRemoteSizeDisplay(e) }}</span>
2484
- <span class="date">{{ e.modified | date:'yyyy-MM-dd HH:mm' }}</span>
2485
- </div>
2486
- </div>
2487
- <div class="pane-actions-bar">
2488
- <div class="selection" *ngIf="selectedRemote.length">
2489
- Selected: {{ selectedRemote.length === 1 ? selectedRemote[0].name : (selectedRemote.length + ' items') }}
2490
- </div>
2491
- <div class="action-inputs">
2492
- <input [(ngModel)]="remoteActionName" placeholder="Name / new name" />
2493
- <input [(ngModel)]="remoteActionPerms" placeholder="Perms (e.g. 755)" />
2494
- </div>
2495
- <div class="action-buttons">
2496
- <button (click)="remoteRename()" [disabled]="selectedRemote.length !== 1">Rename</button>
2497
- <button (click)="refreshRemote()" [disabled]="!connected">Refresh</button>
2498
- <button (click)="remoteDelete()" [disabled]="!selectedRemote.length">Delete</button>
2499
- <button (click)="remoteNewFolder()" [disabled]="!connected">New Folder</button>
2500
- <button (click)="remoteEditPermissions()" [disabled]="selectedRemote.length !== 1 || !remoteActionPerms">Edit Permissions</button>
2501
- <button (click)="remoteShowSize()" [disabled]="selectedRemote.length !== 1 || !selectedRemote[0].isDirectory">Show Size</button>
2502
- <button (click)="remoteDownload()" [disabled]="!selectedRemote.length">Download</button>
2503
- </div>
2504
- </div>
2505
- </div>
2506
- </div>
2507
- <div class="sftp-transfers" *ngIf="transfers.length">
2508
- <div class="transfer" *ngFor="let t of transfers">
2509
- <div class="transfer-main">
2510
- <div class="transfer-title">
2511
- <span class="direction">{{ t.direction === 'upload' ? 'Upload' : 'Download' }}</span>
2512
- <span class="name">{{ t.name }}</span>
2513
- </div>
2514
- <div class="transfer-path">
2515
- <span class="label">Remote:</span>
2516
- <span class="value">{{ t.remotePath }}</span>
2517
- </div>
2518
- <div class="transfer-path">
2519
- <span class="label">Local:</span>
2520
- <span class="value">{{ t.localPath }}</span>
2521
- </div>
2522
- <div class="bar">
2523
- <div class="fill" [style.width.%]="getTransferProgress(t.transfer)"></div>
2524
- </div>
2525
- </div>
2526
- <div class="transfer-stats">
2527
- <div class="percent">{{ getTransferProgress(t.transfer) | number:'1.0-0' }}%</div>
2528
- <div class="speed">{{ formatSpeed(t.transfer.getSpeed()) }}</div>
2529
- <button class="btn-cancel" (click)="cancelTransfer(t)" [disabled]="t.transfer.isComplete() || t.transfer.isCancelled()">Cancel</button>
2530
- </div>
2531
- </div>
2532
- </div>
2533
-
2534
- <div class="delete-overlay" *ngIf="deleteConfirmVisible">
2535
- <div class="delete-dialog">
2536
- <div class="delete-text">{{ deleteConfirmText }}</div>
2537
- <div class="delete-buttons">
2538
- <button class="danger" (click)="confirmDelete()">Delete</button>
2539
- <button (click)="cancelDelete()">Cancel</button>
2540
- </div>
2541
- </div>
2542
- </div>
2543
-
2544
- <div class="delete-overlay" *ngIf="replaceConfirmVisible">
2545
- <div class="delete-dialog">
2546
- <div class="delete-text">{{ replaceConfirmText }}</div>
2547
- <div class="delete-buttons">
2548
- <button class="danger" (click)="confirmReplace()">Replace</button>
2549
- <button (click)="cancelReplace()">Cancel</button>
2550
- </div>
2551
- </div>
2552
- </div>
2553
-
2554
- <div class="delete-overlay" *ngIf="inputDialogVisible">
2555
- <div class="delete-dialog" (click)="$event.stopPropagation()">
2556
- <div class="delete-text">{{ inputDialogTitle }}</div>
2557
- <input
2558
- class="dialog-input"
2559
- [(ngModel)]="inputDialogValue"
2560
- [placeholder]="inputDialogPlaceholder"
2561
- (keyup.enter)="confirmInputDialog()"
2562
- />
2563
- <div class="delete-buttons">
2564
- <button class="danger" (click)="confirmInputDialog()" [disabled]="!inputDialogValue.trim()">OK</button>
2565
- <button (click)="cancelInputDialog()">Cancel</button>
2566
- </div>
2567
- </div>
2568
- </div>
2569
-
2570
- <div
2571
- class="local-menu"
2572
- *ngIf="localMenuVisible"
2573
- [style.left.px]="localMenuX"
2574
- [style.top.px]="localMenuY"
2575
- (click)="$event.stopPropagation()"
2576
- >
2577
- <div class="local-menu-item" *ngFor="let item of localMenuItems" (click)="onLocalMenuItemClick(item)">
2578
- {{ item.label }}
2579
- </div>
2580
- </div>
2581
- </div>
2349
+ template: `
2350
+ <div class="sftp-root" tabindex="0" (keydown)="onKeyDown($event)">
2351
+ <div class="top-profiles" *ngIf="profile || recentProfiles.length">
2352
+ <div class="current" *ngIf="profile">
2353
+ <span class="label">Device:</span>
2354
+ <span class="value">{{ getProfileLabel(profile) }}</span>
2355
+ </div>
2356
+ <div class="recent" *ngIf="recentProfiles.length">
2357
+ <span class="label">Recent:</span>
2358
+ <button
2359
+ class="profile-chip"
2360
+ *ngFor="let p of recentProfiles"
2361
+ (click)="launchProfileFromSFTP(p)"
2362
+ >
2363
+ {{ getProfileLabel(p) }}
2364
+ </button>
2365
+ </div>
2366
+ </div>
2367
+ <div class="sftp-body">
2368
+ <div class="pane">
2369
+ <div class="pane-title">
2370
+ <div class="pane-label">Local</div>
2371
+ <div class="pane-path">
2372
+ <input
2373
+ [(ngModel)]="localPathInput"
2374
+ (keyup.enter)="goToLocalPathInput()"
2375
+ />
2376
+ </div>
2377
+ <div class="pane-actions">
2378
+ <select class="path-preset" (change)="onLocalPresetChange($event.target.value)">
2379
+ <option value="">Go to…</option>
2380
+ <option *ngFor="let p of localPathPresets" [value]="p.id">
2381
+ {{ p.label }}
2382
+ </option>
2383
+ </select>
2384
+ <button
2385
+ class="fav-toggle"
2386
+ [class.active]="isCurrentFavorite()"
2387
+ (click)="toggleCurrentFavorite()"
2388
+ title="Toggle favorite for this path"
2389
+ >
2390
+
2391
+ </button>
2392
+ <select class="path-favorite" (change)="onLocalFavoriteSelect($event.target.value)">
2393
+ <option value="">Favorites…</option>
2394
+ <option *ngFor="let f of localFavorites" [value]="f.id">
2395
+ {{ f.label }}
2396
+ </option>
2397
+ </select>
2398
+ <button (click)="localUp()" [disabled]="!canLocalUp()">Up</button>
2399
+ <button (click)="goToLocalPathInput()">Go</button>
2400
+ <button (click)="refreshLocal()">Refresh</button>
2401
+ </div>
2402
+ </div>
2403
+ <div class="pane-filters">
2404
+ <div class="breadcrumbs">
2405
+ <ng-container *ngFor="let part of getLocalBreadcrumbs(); let i = index; let last = last">
2406
+ <button
2407
+ class="crumb-button"
2408
+ (click)="navigateLocalBreadcrumb(i)"
2409
+ (contextmenu)="onLocalBreadcrumbContextMenu(i, $event)"
2410
+ >
2411
+ {{ part.label }}
2412
+ </button>
2413
+ <span class="crumb-separator" *ngIf="!last">›</span>
2414
+ </ng-container>
2415
+ </div>
2416
+ <input [(ngModel)]="localFilter" placeholder="Filter files..." />
2417
+ <label class="show-hidden-toggle">
2418
+ <input type="checkbox" [(ngModel)]="showHiddenLocal" />
2419
+ <span>Show hidden</span>
2420
+ </label>
2421
+ </div>
2422
+ <div class="pane-list"
2423
+ (dragover)="onDragOver($event)"
2424
+ (drop)="onDropOnLocal($event)"
2425
+ >
2426
+ <div class="entry header">
2427
+ <span class="icon"></span>
2428
+ <span class="name sortable" (click)="setLocalSort('name')">Name</span>
2429
+ <span class="size sortable" (click)="setLocalSort('size')">Size</span>
2430
+ <span class="date sortable" (click)="setLocalSort('modified')">Modified</span>
2431
+ </div>
2432
+ <div
2433
+ class="entry"
2434
+ *ngIf="canLocalUp()"
2435
+ (dblclick)="localUp()"
2436
+ >
2437
+ <span class="icon">⬆</span>
2438
+ <span class="name">Go up</span>
2439
+ <span class="size"></span>
2440
+ <span class="date"></span>
2441
+ </div>
2442
+ <div
2443
+ class="entry"
2444
+ *ngFor="let e of getFilteredLocalEntries()"
2445
+ (click)="selectLocal(e, $event)"
2446
+ (dblclick)="openLocal(e)"
2447
+ (mousedown)="onLocalMouseDown(e, $event)"
2448
+ (contextmenu)="onLocalContextMenu(e, $event)"
2449
+ (dragover)="onLocalEntryDragOver(e, $event)"
2450
+ (drop)="onLocalEntryDrop(e, $event)"
2451
+ [class.drop-target]="localDropActive"
2452
+ [class.selected]="isLocalSelected(e)"
2453
+ [draggable]="true"
2454
+ (dragstart)="onDragStartLocal($event, e)"
2455
+ >
2456
+ <span class="icon">{{ e.isDirectory ? '📁' : '📄' }}</span>
2457
+ <span class="name">{{ e.name }}</span>
2458
+ <span class="size">{{ getLocalSizeDisplay(e) }}</span>
2459
+ <span class="date">{{ e.mtimeMs ? (e.mtimeMs | date:'yyyy-MM-dd HH:mm') : '' }}</span>
2460
+ </div>
2461
+ </div>
2462
+ <div class="pane-actions-bar">
2463
+ <div class="selection" *ngIf="selectedLocal.length">
2464
+ Selected: {{ selectedLocal.length === 1 ? selectedLocal[0].name : (selectedLocal.length + ' items') }}
2465
+ </div>
2466
+ <div class="action-inputs">
2467
+ <input [(ngModel)]="localActionName" placeholder="Name / new name" />
2468
+ <input [(ngModel)]="localActionPerms" placeholder="Perms (e.g. 755)" />
2469
+ </div>
2470
+ <div class="action-buttons">
2471
+ <button (click)="localRename()" [disabled]="selectedLocal.length !== 1">Rename</button>
2472
+ <button (click)="refreshLocal()">Refresh</button>
2473
+ <button (click)="localDelete()" [disabled]="!selectedLocal.length">Delete</button>
2474
+ <button (click)="localNewFolder()">New Folder</button>
2475
+ <button (click)="localEditPermissions()" [disabled]="selectedLocal.length !== 1 || !localActionPerms">Edit Permissions</button>
2476
+ <button (click)="localShowSize()" [disabled]="selectedLocal.length !== 1 || !selectedLocal[0].isDirectory">Show Size</button>
2477
+ </div>
2478
+ </div>
2479
+ </div>
2480
+
2481
+ <div class="pane">
2482
+ <div class="pane-title">
2483
+ <div class="pane-label">
2484
+ Remote
2485
+ <span *ngIf="connected && profile?.options?.host" class="pane-sub">
2486
+ — {{ profile.options.host }}
2487
+ </span>
2488
+ </div>
2489
+ <div class="pane-path">
2490
+ <input
2491
+ [(ngModel)]="remotePathInput"
2492
+ (keyup.enter)="goToRemotePathInput()"
2493
+ [disabled]="!connected"
2494
+ />
2495
+ </div>
2496
+ <div class="pane-actions">
2497
+ <button (click)="remoteUp()" [disabled]="!connected || remotePath === '/'">Up</button>
2498
+ <button (click)="goToRemotePathInput()" [disabled]="!connected">Go</button>
2499
+ <button (click)="refreshRemote()" [disabled]="!connected">Refresh</button>
2500
+ </div>
2501
+ </div>
2502
+ <div class="pane-filters">
2503
+ <div class="breadcrumbs" *ngIf="connected">
2504
+ <ng-container *ngFor="let part of getRemoteBreadcrumbs(); let i = index; let last = last">
2505
+ <button
2506
+ class="crumb-button"
2507
+ (click)="navigateRemoteBreadcrumb(i)"
2508
+ >
2509
+ {{ part.label }}
2510
+ </button>
2511
+ <span class="crumb-separator" *ngIf="!last">›</span>
2512
+ </ng-container>
2513
+ </div>
2514
+ <input [(ngModel)]="remoteFilter" placeholder="Filter files..." />
2515
+ <label class="show-hidden-toggle">
2516
+ <input type="checkbox" [(ngModel)]="showHiddenRemote" />
2517
+ <span>Show hidden</span>
2518
+ </label>
2519
+ </div>
2520
+ <div class="pane-list"
2521
+ (dragover)="onDragOver($event)"
2522
+ (drop)="onDropOnRemote($event)"
2523
+ >
2524
+ <div class="entry dim" *ngIf="!connected">
2525
+ <span class="name">Not connected</span>
2526
+ </div>
2527
+ <div class="entry header" *ngIf="connected">
2528
+ <span class="icon"></span>
2529
+ <span class="name sortable" (click)="setRemoteSort('name')">Name</span>
2530
+ <span class="size sortable" (click)="setRemoteSort('size')">Size</span>
2531
+ <span class="date sortable" (click)="setRemoteSort('modified')">Modified</span>
2532
+ </div>
2533
+ <div
2534
+ class="entry"
2535
+ *ngIf="connected && remotePath !== '/'"
2536
+ (dblclick)="remoteUp()"
2537
+ >
2538
+ <span class="icon">⬆</span>
2539
+ <span class="name">Go up</span>
2540
+ <span class="size"></span>
2541
+ <span class="date"></span>
2542
+ </div>
2543
+ <div
2544
+ class="entry"
2545
+ *ngFor="let e of getFilteredRemoteEntries()"
2546
+ (click)="selectRemote(e, $event)"
2547
+ (dblclick)="openRemote(e)"
2548
+ (mousedown)="onRemoteMouseDown(e, $event)"
2549
+ (contextmenu)="onRemoteContextMenu(e, $event)"
2550
+ (dragover)="onRemoteEntryDragOver(e, $event)"
2551
+ (drop)="onRemoteEntryDrop(e, $event)"
2552
+ [class.drop-target]="remoteDropActive"
2553
+ [class.selected]="isRemoteSelected(e)"
2554
+ [draggable]="connected"
2555
+ (dragstart)="onDragStartRemote($event, e)"
2556
+ >
2557
+ <span class="icon">{{ e.isDirectory ? '📁' : '📄' }}</span>
2558
+ <span class="name">{{ e.name }}</span>
2559
+ <span class="size">{{ getRemoteSizeDisplay(e) }}</span>
2560
+ <span class="date">{{ e.modified | date:'yyyy-MM-dd HH:mm' }}</span>
2561
+ </div>
2562
+ </div>
2563
+ <div class="pane-actions-bar">
2564
+ <div class="selection" *ngIf="selectedRemote.length">
2565
+ Selected: {{ selectedRemote.length === 1 ? selectedRemote[0].name : (selectedRemote.length + ' items') }}
2566
+ </div>
2567
+ <div class="action-inputs">
2568
+ <input [(ngModel)]="remoteActionName" placeholder="Name / new name" />
2569
+ <input [(ngModel)]="remoteActionPerms" placeholder="Perms (e.g. 755)" />
2570
+ </div>
2571
+ <div class="action-buttons">
2572
+ <button (click)="remoteRename()" [disabled]="selectedRemote.length !== 1">Rename</button>
2573
+ <button (click)="refreshRemote()" [disabled]="!connected">Refresh</button>
2574
+ <button (click)="remoteDelete()" [disabled]="!selectedRemote.length">Delete</button>
2575
+ <button (click)="remoteNewFolder()" [disabled]="!connected">New Folder</button>
2576
+ <button (click)="remoteEditPermissions()" [disabled]="selectedRemote.length !== 1 || !remoteActionPerms">Edit Permissions</button>
2577
+ <button (click)="remoteShowSize()" [disabled]="selectedRemote.length !== 1 || !selectedRemote[0].isDirectory">Show Size</button>
2578
+ <button (click)="remoteDownload()" [disabled]="!selectedRemote.length">Download</button>
2579
+ </div>
2580
+ </div>
2581
+ </div>
2582
+ </div>
2583
+ <div class="sftp-transfers" *ngIf="transfers.length">
2584
+ <div class="transfer" *ngFor="let t of transfers">
2585
+ <div class="transfer-main">
2586
+ <div class="transfer-title">
2587
+ <span class="direction">{{ t.direction === 'upload' ? 'Upload' : 'Download' }}</span>
2588
+ <span class="name">{{ t.name }}</span>
2589
+ </div>
2590
+ <div class="transfer-path">
2591
+ <span class="label">Remote:</span>
2592
+ <span class="value">{{ t.remotePath }}</span>
2593
+ </div>
2594
+ <div class="transfer-path">
2595
+ <span class="label">Local:</span>
2596
+ <span class="value">{{ t.localPath }}</span>
2597
+ </div>
2598
+ <div class="bar">
2599
+ <div class="fill" [style.width.%]="getTransferProgress(t.transfer)"></div>
2600
+ </div>
2601
+ </div>
2602
+ <div class="transfer-stats">
2603
+ <div class="percent">{{ getTransferProgress(t.transfer) | number:'1.0-0' }}%</div>
2604
+ <div class="speed">{{ formatSpeed(t.transfer.getSpeed()) }}</div>
2605
+ <button class="btn-cancel" (click)="cancelTransfer(t)" [disabled]="t.transfer.isComplete() || t.transfer.isCancelled()">Cancel</button>
2606
+ </div>
2607
+ </div>
2608
+ </div>
2609
+
2610
+ <div class="delete-overlay" *ngIf="deleteConfirmVisible">
2611
+ <div class="delete-dialog">
2612
+ <div class="delete-text">{{ deleteConfirmText }}</div>
2613
+ <div class="delete-buttons">
2614
+ <button class="danger" (click)="confirmDelete()">Delete</button>
2615
+ <button (click)="cancelDelete()">Cancel</button>
2616
+ </div>
2617
+ </div>
2618
+ </div>
2619
+
2620
+ <div class="delete-overlay" *ngIf="replaceConfirmVisible">
2621
+ <div class="delete-dialog">
2622
+ <div class="delete-text">{{ replaceConfirmText }}</div>
2623
+ <div class="delete-buttons">
2624
+ <button class="danger" (click)="confirmReplace()">Replace</button>
2625
+ <button (click)="cancelReplace()">Cancel</button>
2626
+ </div>
2627
+ </div>
2628
+ </div>
2629
+
2630
+ <div class="delete-overlay" *ngIf="inputDialogVisible">
2631
+ <div class="delete-dialog" (click)="$event.stopPropagation()">
2632
+ <div class="delete-text">{{ inputDialogTitle }}</div>
2633
+ <input
2634
+ class="dialog-input"
2635
+ [(ngModel)]="inputDialogValue"
2636
+ [placeholder]="inputDialogPlaceholder"
2637
+ (keyup.enter)="confirmInputDialog()"
2638
+ />
2639
+ <div class="delete-buttons">
2640
+ <button class="danger" (click)="confirmInputDialog()" [disabled]="!inputDialogValue.trim()">OK</button>
2641
+ <button (click)="cancelInputDialog()">Cancel</button>
2642
+ </div>
2643
+ </div>
2644
+ </div>
2645
+
2646
+ <div
2647
+ class="local-menu"
2648
+ *ngIf="localMenuVisible"
2649
+ [style.left.px]="localMenuX"
2650
+ [style.top.px]="localMenuY"
2651
+ (click)="$event.stopPropagation()"
2652
+ >
2653
+ <div class="local-menu-item" *ngFor="let item of localMenuItems" (click)="onLocalMenuItemClick(item)">
2654
+ {{ item.label }}
2655
+ </div>
2656
+ </div>
2657
+ </div>
2582
2658
  `,
2583
- styles: [`
2584
- .sftp-root { display: flex; flex-direction: column; height: 100%; padding: 10px; gap: 10px; position: relative; }
2585
- button { padding: 6px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; }
2586
- button:disabled { opacity: 0.5; cursor: default; }
2587
- .top-profiles { display: flex; justify-content: space-between; align-items: center; padding: 4px 8px 8px; gap: 12px; font-size: 11px; opacity: 0.9; }
2588
- .top-profiles .current .label,
2589
- .top-profiles .recent .label { text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; margin-right: 4px; }
2590
- .top-profiles .value { font-weight: 600; }
2591
- .top-profiles .profile-chip { padding: 2px 8px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.04); color: inherit; cursor: pointer; font-size: 11px; }
2592
- .top-profiles .profile-chip:hover { background: rgba(255,255,255,0.12); }
2593
- .sftp-body { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; flex: 1; min-height: 0; }
2594
- .pane { display: flex; flex-direction: column; border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; overflow: hidden; min-height: 0; }
2595
- .pane-title { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; padding: 8px 10px; background: rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.08); }
2596
- .pane-label { font-weight: 600; display: flex; align-items: baseline; gap: 6px; }
2597
- .pane-sub { font-weight: 400; font-size: 11px; opacity: 0.75; }
2598
- .pane-path { opacity: 0.8; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2599
- .pane-path input { width: 100%; padding: 4px 6px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.18); background: rgba(0,0,0,0.3); color: inherit; font-family: inherit; font-size: 12px; }
2600
- .pane-actions { display: flex; gap: 8px; align-items: center; }
2601
- .pane-actions .path-preset,
2602
- .pane-actions .path-favorite { max-width: 150px; padding: 3px 6px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.22); background: rgba(20,20,20,0.95); color: inherit; font-size: 11px; }
2603
- .pane-actions .path-preset option { background: #151515; color: #f5f5f5; }
2604
- .pane-actions .path-favorite option { background: #151515; color: #f5f5f5; }
2605
- .pane-actions .fav-toggle { padding: 2px 6px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.25); background: rgba(255,255,255,0.05); font-size: 11px; line-height: 1; }
2606
- .pane-actions .fav-toggle.active { background: rgba(255,215,0,0.2); border-color: rgba(255,215,0,0.6); color: #ffd700; }
2607
- .pane-filters { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-bottom: 1px solid rgba(255,255,255,0.06); background: rgba(0,0,0,0.12); }
2608
- .pane-filters input { flex: 1; padding: 4px 6px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.18); background: rgba(0,0,0,0.3); color: inherit; font-size: 12px; }
2609
- .show-hidden-toggle { display: flex; align-items: center; gap: 4px; font-size: 11px; opacity: 0.8; white-space: nowrap; }
2610
- .show-hidden-toggle input[type="checkbox"] { margin: 0; }
2611
- .breadcrumbs { display: flex; flex-wrap: wrap; gap: 4px; font-size: 11px; opacity: 0.9; align-items: center; }
2612
- .crumb-button { padding: 2px 6px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.04); color: inherit; cursor: pointer; font-size: 11px; }
2613
- .crumb-button:hover { background: rgba(255,255,255,0.10); }
2614
- .crumb-separator { opacity: 0.6; }
2615
- .pane-list { flex: 1; overflow: auto; padding: 4px; }
2616
- .entry { display: grid; grid-template-columns: 24px minmax(0, 1.5fr) 80px 140px; gap: 8px; padding: 6px 8px; border-radius: 8px; user-select: none; align-items: center; }
2617
- .entry:hover { background: rgba(255,255,255,0.06); }
2618
- .entry.drop-target { outline: 1px dashed rgba(255,255,255,0.35); background: rgba(80, 160, 255, 0.10); }
2619
- .entry.dim { opacity: 0.7; }
2620
- .icon { text-align: center; opacity: 0.85; }
2621
- .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2622
- .size { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; text-align: right; opacity: 0.8; }
2623
- .date { font-size: 11px; opacity: 0.75; text-align: right; white-space: nowrap; }
2624
- .entry.header { font-weight: 600; opacity: 0.9; background: rgba(255,255,255,0.02); }
2625
- .sortable { cursor: pointer; }
2626
- .entry.selected { background: rgba(80,160,255,0.18); }
2627
- .pane-actions-bar { display: flex; flex-direction: column; gap: 4px; padding: 6px 8px; border-top: 1px solid rgba(255,255,255,0.06); background: rgba(0,0,0,0.18); }
2628
- .pane-actions-bar .selection { font-size: 11px; opacity: 0.85; }
2629
- .pane-actions-bar .action-inputs { display: flex; gap: 6px; }
2630
- .pane-actions-bar .action-inputs input { flex: 1; padding: 3px 6px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.18); background: rgba(0,0,0,0.3); color: inherit; font-size: 11px; }
2631
- .pane-actions-bar .action-buttons { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
2632
- .sftp-transfers { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; max-height: 120px; overflow-y: auto; }
2633
- .transfer { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 6px 8px; border-radius: 8px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); font-size: 11px; }
2634
- .transfer-title { display: flex; gap: 6px; align-items: baseline; margin-bottom: 2px; }
2635
- .transfer-title .direction { text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.7; font-weight: 600; font-size: 10px; }
2636
- .transfer-title .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2637
- .transfer-path { display: flex; gap: 4px; opacity: 0.75; }
2638
- .transfer-path .label { min-width: 48px; }
2639
- .transfer-path .value { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
2640
- .bar { position: relative; height: 4px; border-radius: 999px; background: rgba(255,255,255,0.07); margin-top: 4px; overflow: hidden; }
2641
- .bar .fill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: inherit; background: linear-gradient(90deg, #4dabff, #78ffce); transition: width 0.15s linear; }
2642
- .transfer-stats { display: flex; flex-direction: column; justify-content: center; align-items: flex-end; gap: 4px; opacity: 0.8; }
2643
- .transfer-stats .percent { font-weight: 600; }
2644
- .transfer-stats .speed { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
2645
- .btn-cancel { padding: 2px 6px; font-size: 10px; border-radius: 999px; }
2646
- .delete-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; z-index: 20; }
2647
- .delete-dialog { min-width: 260px; max-width: 360px; padding: 14px 16px; border-radius: 10px; background: rgba(20,20,20,0.96); border: 1px solid rgba(255,255,255,0.15); box-shadow: 0 18px 45px rgba(0,0,0,0.75); display: flex; flex-direction: column; gap: 10px; }
2648
- .delete-text { font-size: 13px; }
2649
- .dialog-input { width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.18); background: rgba(0,0,0,0.3); color: inherit; font-size: 13px; }
2650
- .delete-buttons { display: flex; justify-content: flex-end; gap: 8px; }
2651
- .delete-buttons .danger { background: rgba(255,80,80,0.85); border-color: rgba(255,120,120,0.85); }
2652
- .local-menu { position: absolute; min-width: 180px; max-width: 260px; max-height: 260px; overflow-y: auto; padding: 4px 0; border-radius: 10px; background: rgba(18,18,22,0.98); border: 1px solid rgba(255,255,255,0.16); box-shadow: 0 18px 45px rgba(0,0,0,0.8); z-index: 30; backdrop-filter: blur(12px); }
2653
- .local-menu-item { padding: 6px 12px; font-size: 12px; cursor: pointer; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
2654
- .local-menu-item:hover { background: linear-gradient(90deg, rgba(120,200,255,0.24), rgba(120,255,206,0.15)); }
2659
+ styles: [`
2660
+ .sftp-root { display: flex; flex-direction: column; height: 100%; padding: 10px; gap: 10px; position: relative; }
2661
+ button { padding: 6px 10px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.06); color: inherit; cursor: pointer; }
2662
+ button:disabled { opacity: 0.5; cursor: default; }
2663
+ .top-profiles { display: flex; justify-content: space-between; align-items: center; padding: 4px 8px 8px; gap: 12px; font-size: 11px; opacity: 0.9; }
2664
+ .top-profiles .current .label,
2665
+ .top-profiles .recent .label { text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; margin-right: 4px; }
2666
+ .top-profiles .value { font-weight: 600; }
2667
+ .top-profiles .profile-chip { padding: 2px 8px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.04); color: inherit; cursor: pointer; font-size: 11px; }
2668
+ .top-profiles .profile-chip:hover { background: rgba(255,255,255,0.12); }
2669
+ .sftp-body { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; flex: 1; min-height: 0; }
2670
+ .pane { display: flex; flex-direction: column; border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; overflow: hidden; min-height: 0; }
2671
+ .pane-title { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 10px; padding: 8px 10px; background: rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.08); }
2672
+ .pane-label { font-weight: 600; display: flex; align-items: baseline; gap: 6px; }
2673
+ .pane-sub { font-weight: 400; font-size: 11px; opacity: 0.75; }
2674
+ .pane-path { opacity: 0.8; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2675
+ .pane-path input { width: 100%; padding: 4px 6px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.18); background: rgba(0,0,0,0.3); color: inherit; font-family: inherit; font-size: 12px; }
2676
+ .pane-actions { display: flex; gap: 8px; align-items: center; }
2677
+ .pane-actions .path-preset,
2678
+ .pane-actions .path-favorite { max-width: 150px; padding: 3px 6px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.22); background: rgba(20,20,20,0.95); color: inherit; font-size: 11px; }
2679
+ .pane-actions .path-preset option { background: #151515; color: #f5f5f5; }
2680
+ .pane-actions .path-favorite option { background: #151515; color: #f5f5f5; }
2681
+ .pane-actions .fav-toggle { padding: 2px 6px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.25); background: rgba(255,255,255,0.05); font-size: 11px; line-height: 1; }
2682
+ .pane-actions .fav-toggle.active { background: rgba(255,215,0,0.2); border-color: rgba(255,215,0,0.6); color: #ffd700; }
2683
+ .pane-filters { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-bottom: 1px solid rgba(255,255,255,0.06); background: rgba(0,0,0,0.12); }
2684
+ .pane-filters input { flex: 1; padding: 4px 6px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.18); background: rgba(0,0,0,0.3); color: inherit; font-size: 12px; }
2685
+ .show-hidden-toggle { display: flex; align-items: center; gap: 4px; font-size: 11px; opacity: 0.8; white-space: nowrap; }
2686
+ .show-hidden-toggle input[type="checkbox"] { margin: 0; }
2687
+ .breadcrumbs { display: flex; flex-wrap: wrap; gap: 4px; font-size: 11px; opacity: 0.9; align-items: center; }
2688
+ .crumb-button { padding: 2px 6px; border-radius: 999px; border: 1px solid rgba(255,255,255,0.18); background: rgba(255,255,255,0.04); color: inherit; cursor: pointer; font-size: 11px; }
2689
+ .crumb-button:hover { background: rgba(255,255,255,0.10); }
2690
+ .crumb-separator { opacity: 0.6; }
2691
+ .pane-list { flex: 1; overflow: auto; padding: 4px; }
2692
+ .entry { display: grid; grid-template-columns: 24px minmax(0, 1.5fr) 80px 140px; gap: 8px; padding: 6px 8px; border-radius: 8px; user-select: none; align-items: center; }
2693
+ .entry:hover { background: rgba(255,255,255,0.06); }
2694
+ .entry.drop-target { outline: 1px dashed rgba(255,255,255,0.35); background: rgba(80, 160, 255, 0.10); }
2695
+ .entry.dim { opacity: 0.7; }
2696
+ .icon { text-align: center; opacity: 0.85; }
2697
+ .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2698
+ .size { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; text-align: right; opacity: 0.8; }
2699
+ .date { font-size: 11px; opacity: 0.75; text-align: right; white-space: nowrap; }
2700
+ .entry.header { font-weight: 600; opacity: 0.9; background: rgba(255,255,255,0.02); }
2701
+ .sortable { cursor: pointer; }
2702
+ .entry.selected { background: rgba(80,160,255,0.18); }
2703
+ .pane-actions-bar { display: flex; flex-direction: column; gap: 4px; padding: 6px 8px; border-top: 1px solid rgba(255,255,255,0.06); background: rgba(0,0,0,0.18); }
2704
+ .pane-actions-bar .selection { font-size: 11px; opacity: 0.85; }
2705
+ .pane-actions-bar .action-inputs { display: flex; gap: 6px; }
2706
+ .pane-actions-bar .action-inputs input { flex: 1; padding: 3px 6px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.18); background: rgba(0,0,0,0.3); color: inherit; font-size: 11px; }
2707
+ .pane-actions-bar .action-buttons { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
2708
+ .sftp-transfers { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; max-height: 120px; overflow-y: auto; }
2709
+ .transfer { display: grid; grid-template-columns: 1fr auto; gap: 8px; padding: 6px 8px; border-radius: 8px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); font-size: 11px; }
2710
+ .transfer-title { display: flex; gap: 6px; align-items: baseline; margin-bottom: 2px; }
2711
+ .transfer-title .direction { text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.7; font-weight: 600; font-size: 10px; }
2712
+ .transfer-title .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2713
+ .transfer-path { display: flex; gap: 4px; opacity: 0.75; }
2714
+ .transfer-path .label { min-width: 48px; }
2715
+ .transfer-path .value { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
2716
+ .bar { position: relative; height: 4px; border-radius: 999px; background: rgba(255,255,255,0.07); margin-top: 4px; overflow: hidden; }
2717
+ .bar .fill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: inherit; background: linear-gradient(90deg, #4dabff, #78ffce); transition: width 0.15s linear; }
2718
+ .transfer-stats { display: flex; flex-direction: column; justify-content: center; align-items: flex-end; gap: 4px; opacity: 0.8; }
2719
+ .transfer-stats .percent { font-weight: 600; }
2720
+ .transfer-stats .speed { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
2721
+ .btn-cancel { padding: 2px 6px; font-size: 10px; border-radius: 999px; }
2722
+ .delete-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; z-index: 20; }
2723
+ .delete-dialog { min-width: 260px; max-width: 360px; padding: 14px 16px; border-radius: 10px; background: rgba(20,20,20,0.96); border: 1px solid rgba(255,255,255,0.15); box-shadow: 0 18px 45px rgba(0,0,0,0.75); display: flex; flex-direction: column; gap: 10px; }
2724
+ .delete-text { font-size: 13px; }
2725
+ .dialog-input { width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.18); background: rgba(0,0,0,0.3); color: inherit; font-size: 13px; }
2726
+ .delete-buttons { display: flex; justify-content: flex-end; gap: 8px; }
2727
+ .delete-buttons .danger { background: rgba(255,80,80,0.85); border-color: rgba(255,120,120,0.85); }
2728
+ .local-menu { position: absolute; min-width: 180px; max-width: 260px; max-height: 260px; overflow-y: auto; padding: 4px 0; border-radius: 10px; background: rgba(18,18,22,0.98); border: 1px solid rgba(255,255,255,0.16); box-shadow: 0 18px 45px rgba(0,0,0,0.8); z-index: 30; backdrop-filter: blur(12px); }
2729
+ .local-menu-item { padding: 6px 12px; font-size: 12px; cursor: pointer; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
2730
+ .local-menu-item:hover { background: linear-gradient(90deg, rgba(120,200,255,0.24), rgba(120,255,206,0.15)); }
2655
2731
  `],
2656
2732
  }),
2657
2733
  __metadata("design:paramtypes", [_angular_core__WEBPACK_IMPORTED_MODULE_5__.Injector,
@@ -2662,6 +2738,57 @@ SftpManagerTabComponent = __decorate([
2662
2738
 
2663
2739
 
2664
2740
 
2741
+ /***/ },
2742
+
2743
+ /***/ "./src/sftp-recovery-provider.ts"
2744
+ /*!***************************************!*\
2745
+ !*** ./src/sftp-recovery-provider.ts ***!
2746
+ \***************************************/
2747
+ (__unused_webpack_module, __webpack_exports__, __webpack_require__) {
2748
+
2749
+ __webpack_require__.r(__webpack_exports__);
2750
+ /* harmony export */ __webpack_require__.d(__webpack_exports__, {
2751
+ /* harmony export */ SFTP_RECOVERY_TYPE: () => (/* binding */ SFTP_RECOVERY_TYPE),
2752
+ /* harmony export */ SftpTabRecoveryProvider: () => (/* binding */ SftpTabRecoveryProvider)
2753
+ /* harmony export */ });
2754
+ /* harmony import */ var _angular_core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @angular/core */ "@angular/core");
2755
+ /* harmony import */ var _angular_core__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_angular_core__WEBPACK_IMPORTED_MODULE_0__);
2756
+ /* harmony import */ var tabby_core__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! tabby-core */ "tabby-core");
2757
+ /* harmony import */ var tabby_core__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(tabby_core__WEBPACK_IMPORTED_MODULE_1__);
2758
+ /* harmony import */ var _sftp_manager_tab_component__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./sftp-manager-tab.component */ "./src/sftp-manager-tab.component.ts");
2759
+ var __decorate = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
2760
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
2761
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
2762
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
2763
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
2764
+ };
2765
+
2766
+
2767
+
2768
+ const SFTP_RECOVERY_TYPE = 'tabby-sftp-ui:sftp-tab';
2769
+ let SftpTabRecoveryProvider = class SftpTabRecoveryProvider extends tabby_core__WEBPACK_IMPORTED_MODULE_1__.TabRecoveryProvider {
2770
+ async applicableTo(recoveryToken) {
2771
+ return recoveryToken?.type === SFTP_RECOVERY_TYPE;
2772
+ }
2773
+ async recover(_recoveryToken) {
2774
+ // Restore a stub tab that immediately closes itself and its SplitTab wrapper.
2775
+ return {
2776
+ type: _sftp_manager_tab_component__WEBPACK_IMPORTED_MODULE_2__.SftpManagerTabComponent,
2777
+ inputs: {
2778
+ sshSession: null,
2779
+ profile: null,
2780
+ recoveredStub: true,
2781
+ },
2782
+ };
2783
+ }
2784
+ };
2785
+ SftpTabRecoveryProvider = __decorate([
2786
+ (0,_angular_core__WEBPACK_IMPORTED_MODULE_0__.Injectable)()
2787
+ ], SftpTabRecoveryProvider);
2788
+
2789
+
2790
+
2791
+
2665
2792
  /***/ },
2666
2793
 
2667
2794
  /***/ "./src/sftp-terminal-decorator.ts"
@@ -2699,7 +2826,13 @@ let SftpTerminalDecorator = class SftpTerminalDecorator extends tabby_terminal__
2699
2826
  attach(terminal) {
2700
2827
  super.attach(terminal);
2701
2828
  // Best-effort DOM injection: place button near the existing Reconnect button if present.
2829
+ // Only inject for SSH tabs (with a live sshSession property).
2702
2830
  const tryInsert = () => {
2831
+ // Skip for local terminals (PowerShell, CMD, etc.) - only SSH tabs need this button.
2832
+ const sshSession = terminal.sshSession;
2833
+ if (!sshSession) {
2834
+ return false;
2835
+ }
2703
2836
  try {
2704
2837
  const host = terminal.element?.nativeElement ?? null;
2705
2838
  if (!host) {
@@ -3093,6 +3226,7 @@ __webpack_require__.r(__webpack_exports__);
3093
3226
  /* harmony import */ var _sftp_hotkey__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./sftp-hotkey */ "./src/sftp-hotkey.ts");
3094
3227
  /* harmony import */ var _sftp_ui_service__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ./sftp-ui.service */ "./src/sftp-ui.service.ts");
3095
3228
  /* harmony import */ var _sftp_terminal_decorator__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(/*! ./sftp-terminal-decorator */ "./src/sftp-terminal-decorator.ts");
3229
+ /* harmony import */ var _sftp_recovery_provider__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(/*! ./sftp-recovery-provider */ "./src/sftp-recovery-provider.ts");
3096
3230
  var __decorate = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
3097
3231
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3098
3232
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -3112,6 +3246,7 @@ var __metadata = (undefined && undefined.__metadata) || function (k, v) {
3112
3246
 
3113
3247
 
3114
3248
 
3249
+
3115
3250
  let SftpUiModule = class SftpUiModule {
3116
3251
  constructor(_) { }
3117
3252
  };
@@ -3128,6 +3263,7 @@ SftpUiModule = __decorate([
3128
3263
  { provide: tabby_core__WEBPACK_IMPORTED_MODULE_3__.TabContextMenuItemProvider, useClass: _sftp_context_menu__WEBPACK_IMPORTED_MODULE_6__.SftpContextMenuProvider, multi: true },
3129
3264
  { provide: tabby_core__WEBPACK_IMPORTED_MODULE_3__.HotkeyProvider, useClass: _sftp_hotkey__WEBPACK_IMPORTED_MODULE_7__.SftpUiHotkeyProvider, multi: true },
3130
3265
  { provide: tabby_terminal__WEBPACK_IMPORTED_MODULE_4__.TerminalDecorator, useClass: _sftp_terminal_decorator__WEBPACK_IMPORTED_MODULE_9__.SftpTerminalDecorator, multi: true },
3266
+ { provide: tabby_core__WEBPACK_IMPORTED_MODULE_3__.TabRecoveryProvider, useClass: _sftp_recovery_provider__WEBPACK_IMPORTED_MODULE_10__.SftpTabRecoveryProvider, multi: true },
3131
3267
  _sftp_ui_service__WEBPACK_IMPORTED_MODULE_8__.SftpUiService,
3132
3268
  ],
3133
3269
  }),