tabby-sftp-ui 0.2.5 → 0.2.6

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/README.md CHANGED
@@ -142,6 +142,11 @@ Then restart Tabby.
142
142
 
143
143
  ### Changelog
144
144
 
145
+ - **0.2.6**
146
+ - Fix: Default remote path now detects Linux and Windows correctly (`/home/user`, `/root`, `/C:/Users/user`, etc.).
147
+ - Fix: Uploads work immediately after opening SFTP-UI (no need to change directories first).
148
+ - Fix: Remote path is resolved via SFTP canonicalize, shell CWD, and validated before upload.
149
+
145
150
  - **0.2.5**
146
151
  - Fix: SFTP UI tabs are not persisted across Tabby restarts (no more blank tabs when reopening Tabby).
147
152
  - Fix: SFTP-UI button is shown only on SSH tabs with an active session (not on local PowerShell/CMD terminals).
package/dist/index.js CHANGED
@@ -267,6 +267,7 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
267
267
  this.app = app;
268
268
  // injected from the SSH tab when opened via SFTP-UI button
269
269
  this.sshSession = null;
270
+ this.shellSession = null;
270
271
  this.profile = null;
271
272
  this.recoveredStub = false;
272
273
  this.connecting = false;
@@ -281,6 +282,7 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
281
282
  this.remotePath = '/';
282
283
  this.remoteEntries = [];
283
284
  this.sftpSession = null;
285
+ this.connectPromise = null;
284
286
  this.localDropActive = false;
285
287
  this.remoteDropActive = false;
286
288
  this.transfers = [];
@@ -368,11 +370,32 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
368
370
  this.remotePathInput = this.remotePath;
369
371
  this.localPathInput = this.localPath;
370
372
  if (this.sshSession) {
371
- void this.connect();
373
+ void this.waitForConnection();
372
374
  }
373
375
  this.loadRecentProfiles();
374
376
  }
375
377
  async connect() {
378
+ if (this.connected) {
379
+ return;
380
+ }
381
+ if (this.connectPromise) {
382
+ return this.connectPromise;
383
+ }
384
+ this.connectPromise = this.connectInternal();
385
+ try {
386
+ await this.connectPromise;
387
+ }
388
+ finally {
389
+ this.connectPromise = null;
390
+ }
391
+ }
392
+ async waitForConnection() {
393
+ if (this.connected) {
394
+ return;
395
+ }
396
+ await this.connect();
397
+ }
398
+ async connectInternal() {
376
399
  if (this.connecting || this.connected) {
377
400
  return;
378
401
  }
@@ -384,17 +407,30 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
384
407
  try {
385
408
  this.sftpSession = await this.sftp.openFromSSHSession(this.sshSession);
386
409
  this.connected = true;
387
- this.remotePath = this.getDefaultRemotePath();
388
- this.remotePathInput = this.remotePath;
410
+ await this.initializeRemotePath();
389
411
  await this.refreshRemote();
390
412
  }
391
413
  catch (e) {
392
414
  console.error('[SFTP-UI] SFTP connection failed', e);
415
+ this.connected = false;
416
+ this.sftpSession = null;
393
417
  }
394
418
  finally {
395
419
  this.connecting = false;
396
420
  }
397
421
  }
422
+ async initializeRemotePath() {
423
+ this.remotePath = await this.resolveDefaultRemotePath();
424
+ this.remotePathInput = this.remotePath;
425
+ if (await this.ensureRemoteDirectoryReady()) {
426
+ return;
427
+ }
428
+ // Shell CWD may not be ready yet when SFTP-UI opens immediately after SSH connect.
429
+ await new Promise(resolve => setTimeout(resolve, 500));
430
+ this.remotePath = await this.resolveDefaultRemotePath();
431
+ this.remotePathInput = this.remotePath;
432
+ await this.ensureRemoteDirectoryReady();
433
+ }
398
434
  async disconnect() {
399
435
  this.sftpSession = null;
400
436
  this.connected = false;
@@ -413,14 +449,26 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
413
449
  }
414
450
  }
415
451
  remoteUp() {
416
- if (!this.connected || this.remotePath === '/') {
452
+ if (!this.canRemoteUp()) {
417
453
  return;
418
454
  }
419
- const next = path__WEBPACK_IMPORTED_MODULE_0__.posix.dirname(this.remotePath);
420
- this.remotePath = next === '.' ? '/' : next;
455
+ const next = this.remoteParentPath(this.remotePath);
456
+ this.remotePath = next;
421
457
  this.remotePathInput = this.remotePath;
422
458
  void this.refreshRemote();
423
459
  }
460
+ canRemoteUp() {
461
+ if (!this.connected) {
462
+ return false;
463
+ }
464
+ if (this.remotePath === '/') {
465
+ return false;
466
+ }
467
+ if (this.isWindowsSftpPath(this.remotePath)) {
468
+ return !/^\/[A-Za-z]:\/?$/.test(this.remotePath);
469
+ }
470
+ return true;
471
+ }
424
472
  async refreshLocal() {
425
473
  try {
426
474
  const names = await fs_promises__WEBPACK_IMPORTED_MODULE_2__.readdir(this.localPath);
@@ -633,10 +681,12 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
633
681
  async onDropOnRemote(ev) {
634
682
  ev.preventDefault();
635
683
  this.remoteDropActive = false;
636
- if (!this.connected) {
684
+ await this.waitForConnection();
685
+ if (!this.connected || !this.sftpSession) {
637
686
  return;
638
687
  }
639
- if (!this.sftpSession) {
688
+ if (!(await this.ensureRemoteDirectoryReady())) {
689
+ console.error('[SFTP-UI] Remote path is not ready for upload');
640
690
  return;
641
691
  }
642
692
  // Drag & drop from OS file manager (Explorer/Finder) into the remote pane
@@ -1370,6 +1420,9 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
1370
1420
  }
1371
1421
  }
1372
1422
  getRemoteBreadcrumbs() {
1423
+ if (this.isWindowsSftpPath(this.remotePath)) {
1424
+ return this.getWindowsSftpBreadcrumbs(this.remotePath);
1425
+ }
1373
1426
  const parts = this.remotePath.split('/').filter(Boolean);
1374
1427
  const crumbs = [];
1375
1428
  let current = '/';
@@ -1394,9 +1447,7 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
1394
1447
  if (!this.connected) {
1395
1448
  return;
1396
1449
  }
1397
- const target = this.normalizeRemotePath(this.remotePathInput || '/');
1398
- this.remotePath = target;
1399
- this.remotePathInput = target;
1450
+ this.syncRemotePathFromInput();
1400
1451
  void this.refreshRemote();
1401
1452
  }
1402
1453
  goToLocalPathInput() {
@@ -1583,21 +1634,169 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
1583
1634
  if (!p) {
1584
1635
  return '/';
1585
1636
  }
1586
- let result = p.trim();
1637
+ let result = p.trim().replace(/\\/g, '/');
1638
+ const winDrive = result.match(/^\/([A-Za-z]):(?:\/(.*))?$/);
1639
+ if (winDrive) {
1640
+ const rest = winDrive[2] ?? '';
1641
+ return rest
1642
+ ? `/${winDrive[1]}:/${rest}`.replace(/\/+/g, '/')
1643
+ : `/${winDrive[1]}:/`;
1644
+ }
1645
+ const bareDrive = result.match(/^([A-Za-z]):(?:\/(.*))?$/);
1646
+ if (bareDrive) {
1647
+ const rest = bareDrive[2] ?? '';
1648
+ return rest
1649
+ ? `/${bareDrive[1]}:/${rest}`.replace(/\/+/g, '/')
1650
+ : `/${bareDrive[1]}:/`;
1651
+ }
1587
1652
  if (!result.startsWith('/')) {
1588
1653
  result = '/' + result;
1589
1654
  }
1590
- // remove duplicate slashes
1591
- result = result.replace(/\/+/g, '/');
1592
- return result;
1655
+ return result.replace(/\/+/g, '/');
1593
1656
  }
1594
- getDefaultRemotePath() {
1595
- const username = (this.profile && (this.profile.options?.username || this.profile.options?.user)) || '';
1596
- if (username) {
1597
- return username === 'root' ? `/root` : `/home/${username}`;
1657
+ async resolveDefaultRemotePath() {
1658
+ const canonical = await this.canonicalizeRemotePath('.');
1659
+ if (canonical) {
1660
+ const normalized = this.normalizeRemotePath(canonical);
1661
+ if (await this.remotePathExists(normalized)) {
1662
+ return normalized;
1663
+ }
1664
+ }
1665
+ if (this.shellSession) {
1666
+ try {
1667
+ const cwd = await this.shellSession.getWorkingDirectory?.();
1668
+ if (cwd) {
1669
+ const normalized = this.toRemoteSftpPath(cwd);
1670
+ if (await this.remotePathExists(normalized)) {
1671
+ return normalized;
1672
+ }
1673
+ }
1674
+ }
1675
+ catch {
1676
+ // fall through to SFTP-based detection
1677
+ }
1678
+ }
1679
+ const username = this.getRemoteUsername();
1680
+ for (const candidate of this.buildDefaultRemotePathCandidates(username)) {
1681
+ if (await this.remotePathExists(candidate)) {
1682
+ return candidate;
1683
+ }
1598
1684
  }
1599
1685
  return '/';
1600
1686
  }
1687
+ syncRemotePathFromInput() {
1688
+ const normalized = this.normalizeRemotePath(this.remotePathInput || this.remotePath || '/');
1689
+ this.remotePath = normalized;
1690
+ this.remotePathInput = normalized;
1691
+ }
1692
+ async ensureRemoteDirectoryReady() {
1693
+ if (!this.sftpSession) {
1694
+ return false;
1695
+ }
1696
+ this.syncRemotePathFromInput();
1697
+ if (await this.remotePathExists(this.remotePath)) {
1698
+ return true;
1699
+ }
1700
+ const resolved = await this.resolveDefaultRemotePath();
1701
+ this.remotePath = resolved;
1702
+ this.remotePathInput = resolved;
1703
+ return await this.remotePathExists(resolved);
1704
+ }
1705
+ getRemoteUsername() {
1706
+ return (this.profile && (this.profile.options?.username || this.profile.options?.user)) || '';
1707
+ }
1708
+ buildDefaultRemotePathCandidates(username) {
1709
+ const candidates = [];
1710
+ if (username) {
1711
+ candidates.push(`/C:/Users/${username}`);
1712
+ if (username.toLowerCase() === 'administrator') {
1713
+ candidates.push('/C:/Users/Administrator');
1714
+ }
1715
+ if (username === 'root') {
1716
+ candidates.push('/root');
1717
+ }
1718
+ else {
1719
+ candidates.push(`/home/${username}`);
1720
+ }
1721
+ }
1722
+ candidates.push('/');
1723
+ return candidates;
1724
+ }
1725
+ async canonicalizeRemotePath(p) {
1726
+ const session = this.sftpSession;
1727
+ const candidates = [
1728
+ session?.canonicalize?.bind(session),
1729
+ session?.realpath?.bind(session),
1730
+ session?.sftp?.canonicalize?.bind(session.sftp),
1731
+ session?.sftp?.realpath?.bind(session.sftp),
1732
+ ].filter((fn) => typeof fn === 'function');
1733
+ for (const fn of candidates) {
1734
+ try {
1735
+ return await fn(p);
1736
+ }
1737
+ catch {
1738
+ // try the next available SFTP realpath implementation
1739
+ }
1740
+ }
1741
+ return null;
1742
+ }
1743
+ async remotePathExists(p) {
1744
+ if (!this.sftpSession) {
1745
+ return false;
1746
+ }
1747
+ try {
1748
+ if (this.sftpSession.stat) {
1749
+ const st = await this.sftpSession.stat(p);
1750
+ return st.isDirectory;
1751
+ }
1752
+ await this.sftpSession.readdir(p);
1753
+ return true;
1754
+ }
1755
+ catch {
1756
+ return false;
1757
+ }
1758
+ }
1759
+ toRemoteSftpPath(p) {
1760
+ return this.normalizeRemotePath(p);
1761
+ }
1762
+ isWindowsSftpPath(p) {
1763
+ return /^\/[A-Za-z]:\//.test(p) || /^\/[A-Za-z]:\/?$/.test(p);
1764
+ }
1765
+ getWindowsSftpBreadcrumbs(remotePath) {
1766
+ const match = remotePath.match(/^\/([A-Za-z]):\/?(.*)$/);
1767
+ if (!match) {
1768
+ return [{ label: '/', path: '/' }];
1769
+ }
1770
+ const drive = match[1].toUpperCase();
1771
+ const crumbs = [
1772
+ { label: `${drive}:`, path: `/${drive}:/` },
1773
+ ];
1774
+ let current = `/${drive}:/`;
1775
+ for (const part of match[2].split('/').filter(Boolean)) {
1776
+ current = `${current}${part}/`;
1777
+ crumbs.push({ label: part, path: current.replace(/\/+$/, '') || `/${drive}:/` });
1778
+ }
1779
+ return crumbs;
1780
+ }
1781
+ remoteParentPath(p) {
1782
+ if (this.isWindowsSftpPath(p)) {
1783
+ const match = p.match(/^\/([A-Za-z]):\/?(.*)$/);
1784
+ if (!match) {
1785
+ return '/';
1786
+ }
1787
+ const drive = match[1].toUpperCase();
1788
+ const parts = match[2].split('/').filter(Boolean);
1789
+ if (!parts.length) {
1790
+ return '/';
1791
+ }
1792
+ parts.pop();
1793
+ return parts.length
1794
+ ? `/${drive}:/${parts.join('/')}`
1795
+ : `/${drive}:/`;
1796
+ }
1797
+ const next = path__WEBPACK_IMPORTED_MODULE_0__.posix.dirname(p);
1798
+ return next === '.' ? '/' : next;
1799
+ }
1601
1800
  loadRecentProfiles() {
1602
1801
  try {
1603
1802
  const rec = this.profilesService.getRecentProfiles?.();
@@ -2494,7 +2693,7 @@ SftpManagerTabComponent = __decorate([
2494
2693
  />
2495
2694
  </div>
2496
2695
  <div class="pane-actions">
2497
- <button (click)="remoteUp()" [disabled]="!connected || remotePath === '/'">Up</button>
2696
+ <button (click)="remoteUp()" [disabled]="!connected || !canRemoteUp()">Up</button>
2498
2697
  <button (click)="goToRemotePathInput()" [disabled]="!connected">Go</button>
2499
2698
  <button (click)="refreshRemote()" [disabled]="!connected">Refresh</button>
2500
2699
  </div>
@@ -2532,7 +2731,7 @@ SftpManagerTabComponent = __decorate([
2532
2731
  </div>
2533
2732
  <div
2534
2733
  class="entry"
2535
- *ngIf="connected && remotePath !== '/'"
2734
+ *ngIf="connected && canRemoteUp()"
2536
2735
  (dblclick)="remoteUp()"
2537
2736
  >
2538
2737
  <span class="icon">⬆</span>
@@ -2969,6 +3168,7 @@ let SftpUiService = class SftpUiService {
2969
3168
  inputs: {
2970
3169
  sshSession,
2971
3170
  profile,
3171
+ shellSession: sourceTab?.session ?? null,
2972
3172
  },
2973
3173
  });
2974
3174
  tab.setTitle(`${baseTitle} + SFTP`);