tabby-sftp-ui 0.2.4 → 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 +32 -6
- package/dist/index.js +673 -406
- package/dist/index.js.map +1 -1
- package/dist/sftp-manager-tab.component.d.ts +21 -3
- package/dist/sftp-recovery-provider.d.ts +8 -0
- package/dist/sftp.service.d.ts +7 -0
- package/package.json +44 -44
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.
|
|
@@ -265,7 +267,9 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
|
|
|
265
267
|
this.app = app;
|
|
266
268
|
// injected from the SSH tab when opened via SFTP-UI button
|
|
267
269
|
this.sshSession = null;
|
|
270
|
+
this.shellSession = null;
|
|
268
271
|
this.profile = null;
|
|
272
|
+
this.recoveredStub = false;
|
|
269
273
|
this.connecting = false;
|
|
270
274
|
this.connected = false;
|
|
271
275
|
// legacy UI fields kept for now (not used when opened from SSH tab)
|
|
@@ -278,6 +282,7 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
|
|
|
278
282
|
this.remotePath = '/';
|
|
279
283
|
this.remoteEntries = [];
|
|
280
284
|
this.sftpSession = null;
|
|
285
|
+
this.connectPromise = null;
|
|
281
286
|
this.localDropActive = false;
|
|
282
287
|
this.remoteDropActive = false;
|
|
283
288
|
this.transfers = [];
|
|
@@ -353,9 +358,9 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
|
|
|
353
358
|
// If there's no live SSH session, this tab was likely restored across
|
|
354
359
|
// restart or opened in an invalid context. Close it immediately to avoid
|
|
355
360
|
// an empty, nameless SFTP tab lingering after restart.
|
|
356
|
-
if (!this.sshSession) {
|
|
361
|
+
if (this.recoveredStub || !this.sshSession) {
|
|
357
362
|
try {
|
|
358
|
-
this.app.closeTab(this);
|
|
363
|
+
this.app.closeTab(this.topmostParent ?? this);
|
|
359
364
|
}
|
|
360
365
|
catch (e) {
|
|
361
366
|
console.error('[SFTP-UI] Failed to close invalid SFTP tab', e);
|
|
@@ -365,11 +370,32 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
|
|
|
365
370
|
this.remotePathInput = this.remotePath;
|
|
366
371
|
this.localPathInput = this.localPath;
|
|
367
372
|
if (this.sshSession) {
|
|
368
|
-
void this.
|
|
373
|
+
void this.waitForConnection();
|
|
369
374
|
}
|
|
370
375
|
this.loadRecentProfiles();
|
|
371
376
|
}
|
|
372
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() {
|
|
373
399
|
if (this.connecting || this.connected) {
|
|
374
400
|
return;
|
|
375
401
|
}
|
|
@@ -381,17 +407,30 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
|
|
|
381
407
|
try {
|
|
382
408
|
this.sftpSession = await this.sftp.openFromSSHSession(this.sshSession);
|
|
383
409
|
this.connected = true;
|
|
384
|
-
|
|
385
|
-
this.remotePathInput = this.remotePath;
|
|
410
|
+
await this.initializeRemotePath();
|
|
386
411
|
await this.refreshRemote();
|
|
387
412
|
}
|
|
388
413
|
catch (e) {
|
|
389
414
|
console.error('[SFTP-UI] SFTP connection failed', e);
|
|
415
|
+
this.connected = false;
|
|
416
|
+
this.sftpSession = null;
|
|
390
417
|
}
|
|
391
418
|
finally {
|
|
392
419
|
this.connecting = false;
|
|
393
420
|
}
|
|
394
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
|
+
}
|
|
395
434
|
async disconnect() {
|
|
396
435
|
this.sftpSession = null;
|
|
397
436
|
this.connected = false;
|
|
@@ -410,14 +449,26 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
|
|
|
410
449
|
}
|
|
411
450
|
}
|
|
412
451
|
remoteUp() {
|
|
413
|
-
if (!this.
|
|
452
|
+
if (!this.canRemoteUp()) {
|
|
414
453
|
return;
|
|
415
454
|
}
|
|
416
|
-
const next =
|
|
417
|
-
this.remotePath = next
|
|
455
|
+
const next = this.remoteParentPath(this.remotePath);
|
|
456
|
+
this.remotePath = next;
|
|
418
457
|
this.remotePathInput = this.remotePath;
|
|
419
458
|
void this.refreshRemote();
|
|
420
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
|
+
}
|
|
421
472
|
async refreshLocal() {
|
|
422
473
|
try {
|
|
423
474
|
const names = await fs_promises__WEBPACK_IMPORTED_MODULE_2__.readdir(this.localPath);
|
|
@@ -630,10 +681,12 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
|
|
|
630
681
|
async onDropOnRemote(ev) {
|
|
631
682
|
ev.preventDefault();
|
|
632
683
|
this.remoteDropActive = false;
|
|
633
|
-
|
|
684
|
+
await this.waitForConnection();
|
|
685
|
+
if (!this.connected || !this.sftpSession) {
|
|
634
686
|
return;
|
|
635
687
|
}
|
|
636
|
-
if (!this.
|
|
688
|
+
if (!(await this.ensureRemoteDirectoryReady())) {
|
|
689
|
+
console.error('[SFTP-UI] Remote path is not ready for upload');
|
|
637
690
|
return;
|
|
638
691
|
}
|
|
639
692
|
// Drag & drop from OS file manager (Explorer/Finder) into the remote pane
|
|
@@ -1367,6 +1420,9 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
|
|
|
1367
1420
|
}
|
|
1368
1421
|
}
|
|
1369
1422
|
getRemoteBreadcrumbs() {
|
|
1423
|
+
if (this.isWindowsSftpPath(this.remotePath)) {
|
|
1424
|
+
return this.getWindowsSftpBreadcrumbs(this.remotePath);
|
|
1425
|
+
}
|
|
1370
1426
|
const parts = this.remotePath.split('/').filter(Boolean);
|
|
1371
1427
|
const crumbs = [];
|
|
1372
1428
|
let current = '/';
|
|
@@ -1391,9 +1447,7 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
|
|
|
1391
1447
|
if (!this.connected) {
|
|
1392
1448
|
return;
|
|
1393
1449
|
}
|
|
1394
|
-
|
|
1395
|
-
this.remotePath = target;
|
|
1396
|
-
this.remotePathInput = target;
|
|
1450
|
+
this.syncRemotePathFromInput();
|
|
1397
1451
|
void this.refreshRemote();
|
|
1398
1452
|
}
|
|
1399
1453
|
goToLocalPathInput() {
|
|
@@ -1580,21 +1634,169 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
|
|
|
1580
1634
|
if (!p) {
|
|
1581
1635
|
return '/';
|
|
1582
1636
|
}
|
|
1583
|
-
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
|
+
}
|
|
1584
1652
|
if (!result.startsWith('/')) {
|
|
1585
1653
|
result = '/' + result;
|
|
1586
1654
|
}
|
|
1587
|
-
|
|
1588
|
-
result = result.replace(/\/+/g, '/');
|
|
1589
|
-
return result;
|
|
1655
|
+
return result.replace(/\/+/g, '/');
|
|
1590
1656
|
}
|
|
1591
|
-
|
|
1592
|
-
const
|
|
1593
|
-
if (
|
|
1594
|
-
|
|
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
|
+
}
|
|
1595
1684
|
}
|
|
1596
1685
|
return '/';
|
|
1597
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
|
+
}
|
|
1598
1800
|
loadRecentProfiles() {
|
|
1599
1801
|
try {
|
|
1600
1802
|
const rec = this.profilesService.getRecentProfiles?.();
|
|
@@ -2036,12 +2238,16 @@ let SftpManagerTabComponent = class SftpManagerTabComponent extends tabby_core__
|
|
|
2036
2238
|
}
|
|
2037
2239
|
super.destroy();
|
|
2038
2240
|
}
|
|
2039
|
-
//
|
|
2040
|
-
//
|
|
2041
|
-
//
|
|
2241
|
+
// A custom recovery token ensures that a SplitTab wrapper won't become empty
|
|
2242
|
+
// after restart. On recovery we restore a stub SFTP tab which immediately
|
|
2243
|
+
// closes its `topmostParent` (the SplitTab wrapper).
|
|
2042
2244
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
2043
2245
|
async getRecoveryToken(_options) {
|
|
2044
|
-
return
|
|
2246
|
+
return {
|
|
2247
|
+
type: _sftp_recovery_provider__WEBPACK_IMPORTED_MODULE_9__.SFTP_RECOVERY_TYPE,
|
|
2248
|
+
version: 1,
|
|
2249
|
+
state: {},
|
|
2250
|
+
};
|
|
2045
2251
|
}
|
|
2046
2252
|
async confirmDelete() {
|
|
2047
2253
|
if (!this.deleteConfirmVisible) {
|
|
@@ -2339,388 +2545,388 @@ __decorate([
|
|
|
2339
2545
|
SftpManagerTabComponent = __decorate([
|
|
2340
2546
|
(0,_angular_core__WEBPACK_IMPORTED_MODULE_5__.Component)({
|
|
2341
2547
|
selector: 'tabby-sftp-manager-tab',
|
|
2342
|
-
template: `
|
|
2343
|
-
<div class="sftp-root" tabindex="0" (keydown)="onKeyDown($event)">
|
|
2344
|
-
<div class="top-profiles" *ngIf="profile || recentProfiles.length">
|
|
2345
|
-
<div class="current" *ngIf="profile">
|
|
2346
|
-
<span class="label">Device:</span>
|
|
2347
|
-
<span class="value">{{ getProfileLabel(profile) }}</span>
|
|
2348
|
-
</div>
|
|
2349
|
-
<div class="recent" *ngIf="recentProfiles.length">
|
|
2350
|
-
<span class="label">Recent:</span>
|
|
2351
|
-
<button
|
|
2352
|
-
class="profile-chip"
|
|
2353
|
-
*ngFor="let p of recentProfiles"
|
|
2354
|
-
(click)="launchProfileFromSFTP(p)"
|
|
2355
|
-
>
|
|
2356
|
-
{{ getProfileLabel(p) }}
|
|
2357
|
-
</button>
|
|
2358
|
-
</div>
|
|
2359
|
-
</div>
|
|
2360
|
-
<div class="sftp-body">
|
|
2361
|
-
<div class="pane">
|
|
2362
|
-
<div class="pane-title">
|
|
2363
|
-
<div class="pane-label">Local</div>
|
|
2364
|
-
<div class="pane-path">
|
|
2365
|
-
<input
|
|
2366
|
-
[(ngModel)]="localPathInput"
|
|
2367
|
-
(keyup.enter)="goToLocalPathInput()"
|
|
2368
|
-
/>
|
|
2369
|
-
</div>
|
|
2370
|
-
<div class="pane-actions">
|
|
2371
|
-
<select class="path-preset" (change)="onLocalPresetChange($event.target.value)">
|
|
2372
|
-
<option value="">Go to…</option>
|
|
2373
|
-
<option *ngFor="let p of localPathPresets" [value]="p.id">
|
|
2374
|
-
{{ p.label }}
|
|
2375
|
-
</option>
|
|
2376
|
-
</select>
|
|
2377
|
-
<button
|
|
2378
|
-
class="fav-toggle"
|
|
2379
|
-
[class.active]="isCurrentFavorite()"
|
|
2380
|
-
(click)="toggleCurrentFavorite()"
|
|
2381
|
-
title="Toggle favorite for this path"
|
|
2382
|
-
>
|
|
2383
|
-
★
|
|
2384
|
-
</button>
|
|
2385
|
-
<select class="path-favorite" (change)="onLocalFavoriteSelect($event.target.value)">
|
|
2386
|
-
<option value="">Favorites…</option>
|
|
2387
|
-
<option *ngFor="let f of localFavorites" [value]="f.id">
|
|
2388
|
-
{{ f.label }}
|
|
2389
|
-
</option>
|
|
2390
|
-
</select>
|
|
2391
|
-
<button (click)="localUp()" [disabled]="!canLocalUp()">Up</button>
|
|
2392
|
-
<button (click)="goToLocalPathInput()">Go</button>
|
|
2393
|
-
<button (click)="refreshLocal()">Refresh</button>
|
|
2394
|
-
</div>
|
|
2395
|
-
</div>
|
|
2396
|
-
<div class="pane-filters">
|
|
2397
|
-
<div class="breadcrumbs">
|
|
2398
|
-
<ng-container *ngFor="let part of getLocalBreadcrumbs(); let i = index; let last = last">
|
|
2399
|
-
<button
|
|
2400
|
-
class="crumb-button"
|
|
2401
|
-
(click)="navigateLocalBreadcrumb(i)"
|
|
2402
|
-
(contextmenu)="onLocalBreadcrumbContextMenu(i, $event)"
|
|
2403
|
-
>
|
|
2404
|
-
{{ part.label }}
|
|
2405
|
-
</button>
|
|
2406
|
-
<span class="crumb-separator" *ngIf="!last">›</span>
|
|
2407
|
-
</ng-container>
|
|
2408
|
-
</div>
|
|
2409
|
-
<input [(ngModel)]="localFilter" placeholder="Filter files..." />
|
|
2410
|
-
<label class="show-hidden-toggle">
|
|
2411
|
-
<input type="checkbox" [(ngModel)]="showHiddenLocal" />
|
|
2412
|
-
<span>Show hidden</span>
|
|
2413
|
-
</label>
|
|
2414
|
-
</div>
|
|
2415
|
-
<div class="pane-list"
|
|
2416
|
-
(dragover)="onDragOver($event)"
|
|
2417
|
-
(drop)="onDropOnLocal($event)"
|
|
2418
|
-
>
|
|
2419
|
-
<div class="entry header">
|
|
2420
|
-
<span class="icon"></span>
|
|
2421
|
-
<span class="name sortable" (click)="setLocalSort('name')">Name</span>
|
|
2422
|
-
<span class="size sortable" (click)="setLocalSort('size')">Size</span>
|
|
2423
|
-
<span class="date sortable" (click)="setLocalSort('modified')">Modified</span>
|
|
2424
|
-
</div>
|
|
2425
|
-
<div
|
|
2426
|
-
class="entry"
|
|
2427
|
-
*ngIf="canLocalUp()"
|
|
2428
|
-
(dblclick)="localUp()"
|
|
2429
|
-
>
|
|
2430
|
-
<span class="icon">⬆</span>
|
|
2431
|
-
<span class="name">Go up</span>
|
|
2432
|
-
<span class="size"></span>
|
|
2433
|
-
<span class="date"></span>
|
|
2434
|
-
</div>
|
|
2435
|
-
<div
|
|
2436
|
-
class="entry"
|
|
2437
|
-
*ngFor="let e of getFilteredLocalEntries()"
|
|
2438
|
-
(click)="selectLocal(e, $event)"
|
|
2439
|
-
(dblclick)="openLocal(e)"
|
|
2440
|
-
(mousedown)="onLocalMouseDown(e, $event)"
|
|
2441
|
-
(contextmenu)="onLocalContextMenu(e, $event)"
|
|
2442
|
-
(dragover)="onLocalEntryDragOver(e, $event)"
|
|
2443
|
-
(drop)="onLocalEntryDrop(e, $event)"
|
|
2444
|
-
[class.drop-target]="localDropActive"
|
|
2445
|
-
[class.selected]="isLocalSelected(e)"
|
|
2446
|
-
[draggable]="true"
|
|
2447
|
-
(dragstart)="onDragStartLocal($event, e)"
|
|
2448
|
-
>
|
|
2449
|
-
<span class="icon">{{ e.isDirectory ? '📁' : '📄' }}</span>
|
|
2450
|
-
<span class="name">{{ e.name }}</span>
|
|
2451
|
-
<span class="size">{{ getLocalSizeDisplay(e) }}</span>
|
|
2452
|
-
<span class="date">{{ e.mtimeMs ? (e.mtimeMs | date:'yyyy-MM-dd HH:mm') : '' }}</span>
|
|
2453
|
-
</div>
|
|
2454
|
-
</div>
|
|
2455
|
-
<div class="pane-actions-bar">
|
|
2456
|
-
<div class="selection" *ngIf="selectedLocal.length">
|
|
2457
|
-
Selected: {{ selectedLocal.length === 1 ? selectedLocal[0].name : (selectedLocal.length + ' items') }}
|
|
2458
|
-
</div>
|
|
2459
|
-
<div class="action-inputs">
|
|
2460
|
-
<input [(ngModel)]="localActionName" placeholder="Name / new name" />
|
|
2461
|
-
<input [(ngModel)]="localActionPerms" placeholder="Perms (e.g. 755)" />
|
|
2462
|
-
</div>
|
|
2463
|
-
<div class="action-buttons">
|
|
2464
|
-
<button (click)="localRename()" [disabled]="selectedLocal.length !== 1">Rename</button>
|
|
2465
|
-
<button (click)="refreshLocal()">Refresh</button>
|
|
2466
|
-
<button (click)="localDelete()" [disabled]="!selectedLocal.length">Delete</button>
|
|
2467
|
-
<button (click)="localNewFolder()">New Folder</button>
|
|
2468
|
-
<button (click)="localEditPermissions()" [disabled]="selectedLocal.length !== 1 || !localActionPerms">Edit Permissions</button>
|
|
2469
|
-
<button (click)="localShowSize()" [disabled]="selectedLocal.length !== 1 || !selectedLocal[0].isDirectory">Show Size</button>
|
|
2470
|
-
</div>
|
|
2471
|
-
</div>
|
|
2472
|
-
</div>
|
|
2473
|
-
|
|
2474
|
-
<div class="pane">
|
|
2475
|
-
<div class="pane-title">
|
|
2476
|
-
<div class="pane-label">
|
|
2477
|
-
Remote
|
|
2478
|
-
<span *ngIf="connected && profile?.options?.host" class="pane-sub">
|
|
2479
|
-
— {{ profile.options.host }}
|
|
2480
|
-
</span>
|
|
2481
|
-
</div>
|
|
2482
|
-
<div class="pane-path">
|
|
2483
|
-
<input
|
|
2484
|
-
[(ngModel)]="remotePathInput"
|
|
2485
|
-
(keyup.enter)="goToRemotePathInput()"
|
|
2486
|
-
[disabled]="!connected"
|
|
2487
|
-
/>
|
|
2488
|
-
</div>
|
|
2489
|
-
<div class="pane-actions">
|
|
2490
|
-
<button (click)="remoteUp()" [disabled]="!connected ||
|
|
2491
|
-
<button (click)="goToRemotePathInput()" [disabled]="!connected">Go</button>
|
|
2492
|
-
<button (click)="refreshRemote()" [disabled]="!connected">Refresh</button>
|
|
2493
|
-
</div>
|
|
2494
|
-
</div>
|
|
2495
|
-
<div class="pane-filters">
|
|
2496
|
-
<div class="breadcrumbs" *ngIf="connected">
|
|
2497
|
-
<ng-container *ngFor="let part of getRemoteBreadcrumbs(); let i = index; let last = last">
|
|
2498
|
-
<button
|
|
2499
|
-
class="crumb-button"
|
|
2500
|
-
(click)="navigateRemoteBreadcrumb(i)"
|
|
2501
|
-
>
|
|
2502
|
-
{{ part.label }}
|
|
2503
|
-
</button>
|
|
2504
|
-
<span class="crumb-separator" *ngIf="!last">›</span>
|
|
2505
|
-
</ng-container>
|
|
2506
|
-
</div>
|
|
2507
|
-
<input [(ngModel)]="remoteFilter" placeholder="Filter files..." />
|
|
2508
|
-
<label class="show-hidden-toggle">
|
|
2509
|
-
<input type="checkbox" [(ngModel)]="showHiddenRemote" />
|
|
2510
|
-
<span>Show hidden</span>
|
|
2511
|
-
</label>
|
|
2512
|
-
</div>
|
|
2513
|
-
<div class="pane-list"
|
|
2514
|
-
(dragover)="onDragOver($event)"
|
|
2515
|
-
(drop)="onDropOnRemote($event)"
|
|
2516
|
-
>
|
|
2517
|
-
<div class="entry dim" *ngIf="!connected">
|
|
2518
|
-
<span class="name">Not connected</span>
|
|
2519
|
-
</div>
|
|
2520
|
-
<div class="entry header" *ngIf="connected">
|
|
2521
|
-
<span class="icon"></span>
|
|
2522
|
-
<span class="name sortable" (click)="setRemoteSort('name')">Name</span>
|
|
2523
|
-
<span class="size sortable" (click)="setRemoteSort('size')">Size</span>
|
|
2524
|
-
<span class="date sortable" (click)="setRemoteSort('modified')">Modified</span>
|
|
2525
|
-
</div>
|
|
2526
|
-
<div
|
|
2527
|
-
class="entry"
|
|
2528
|
-
*ngIf="connected &&
|
|
2529
|
-
(dblclick)="remoteUp()"
|
|
2530
|
-
>
|
|
2531
|
-
<span class="icon">⬆</span>
|
|
2532
|
-
<span class="name">Go up</span>
|
|
2533
|
-
<span class="size"></span>
|
|
2534
|
-
<span class="date"></span>
|
|
2535
|
-
</div>
|
|
2536
|
-
<div
|
|
2537
|
-
class="entry"
|
|
2538
|
-
*ngFor="let e of getFilteredRemoteEntries()"
|
|
2539
|
-
(click)="selectRemote(e, $event)"
|
|
2540
|
-
(dblclick)="openRemote(e)"
|
|
2541
|
-
(mousedown)="onRemoteMouseDown(e, $event)"
|
|
2542
|
-
(contextmenu)="onRemoteContextMenu(e, $event)"
|
|
2543
|
-
(dragover)="onRemoteEntryDragOver(e, $event)"
|
|
2544
|
-
(drop)="onRemoteEntryDrop(e, $event)"
|
|
2545
|
-
[class.drop-target]="remoteDropActive"
|
|
2546
|
-
[class.selected]="isRemoteSelected(e)"
|
|
2547
|
-
[draggable]="connected"
|
|
2548
|
-
(dragstart)="onDragStartRemote($event, e)"
|
|
2549
|
-
>
|
|
2550
|
-
<span class="icon">{{ e.isDirectory ? '📁' : '📄' }}</span>
|
|
2551
|
-
<span class="name">{{ e.name }}</span>
|
|
2552
|
-
<span class="size">{{ getRemoteSizeDisplay(e) }}</span>
|
|
2553
|
-
<span class="date">{{ e.modified | date:'yyyy-MM-dd HH:mm' }}</span>
|
|
2554
|
-
</div>
|
|
2555
|
-
</div>
|
|
2556
|
-
<div class="pane-actions-bar">
|
|
2557
|
-
<div class="selection" *ngIf="selectedRemote.length">
|
|
2558
|
-
Selected: {{ selectedRemote.length === 1 ? selectedRemote[0].name : (selectedRemote.length + ' items') }}
|
|
2559
|
-
</div>
|
|
2560
|
-
<div class="action-inputs">
|
|
2561
|
-
<input [(ngModel)]="remoteActionName" placeholder="Name / new name" />
|
|
2562
|
-
<input [(ngModel)]="remoteActionPerms" placeholder="Perms (e.g. 755)" />
|
|
2563
|
-
</div>
|
|
2564
|
-
<div class="action-buttons">
|
|
2565
|
-
<button (click)="remoteRename()" [disabled]="selectedRemote.length !== 1">Rename</button>
|
|
2566
|
-
<button (click)="refreshRemote()" [disabled]="!connected">Refresh</button>
|
|
2567
|
-
<button (click)="remoteDelete()" [disabled]="!selectedRemote.length">Delete</button>
|
|
2568
|
-
<button (click)="remoteNewFolder()" [disabled]="!connected">New Folder</button>
|
|
2569
|
-
<button (click)="remoteEditPermissions()" [disabled]="selectedRemote.length !== 1 || !remoteActionPerms">Edit Permissions</button>
|
|
2570
|
-
<button (click)="remoteShowSize()" [disabled]="selectedRemote.length !== 1 || !selectedRemote[0].isDirectory">Show Size</button>
|
|
2571
|
-
<button (click)="remoteDownload()" [disabled]="!selectedRemote.length">Download</button>
|
|
2572
|
-
</div>
|
|
2573
|
-
</div>
|
|
2574
|
-
</div>
|
|
2575
|
-
</div>
|
|
2576
|
-
<div class="sftp-transfers" *ngIf="transfers.length">
|
|
2577
|
-
<div class="transfer" *ngFor="let t of transfers">
|
|
2578
|
-
<div class="transfer-main">
|
|
2579
|
-
<div class="transfer-title">
|
|
2580
|
-
<span class="direction">{{ t.direction === 'upload' ? 'Upload' : 'Download' }}</span>
|
|
2581
|
-
<span class="name">{{ t.name }}</span>
|
|
2582
|
-
</div>
|
|
2583
|
-
<div class="transfer-path">
|
|
2584
|
-
<span class="label">Remote:</span>
|
|
2585
|
-
<span class="value">{{ t.remotePath }}</span>
|
|
2586
|
-
</div>
|
|
2587
|
-
<div class="transfer-path">
|
|
2588
|
-
<span class="label">Local:</span>
|
|
2589
|
-
<span class="value">{{ t.localPath }}</span>
|
|
2590
|
-
</div>
|
|
2591
|
-
<div class="bar">
|
|
2592
|
-
<div class="fill" [style.width.%]="getTransferProgress(t.transfer)"></div>
|
|
2593
|
-
</div>
|
|
2594
|
-
</div>
|
|
2595
|
-
<div class="transfer-stats">
|
|
2596
|
-
<div class="percent">{{ getTransferProgress(t.transfer) | number:'1.0-0' }}%</div>
|
|
2597
|
-
<div class="speed">{{ formatSpeed(t.transfer.getSpeed()) }}</div>
|
|
2598
|
-
<button class="btn-cancel" (click)="cancelTransfer(t)" [disabled]="t.transfer.isComplete() || t.transfer.isCancelled()">Cancel</button>
|
|
2599
|
-
</div>
|
|
2600
|
-
</div>
|
|
2601
|
-
</div>
|
|
2602
|
-
|
|
2603
|
-
<div class="delete-overlay" *ngIf="deleteConfirmVisible">
|
|
2604
|
-
<div class="delete-dialog">
|
|
2605
|
-
<div class="delete-text">{{ deleteConfirmText }}</div>
|
|
2606
|
-
<div class="delete-buttons">
|
|
2607
|
-
<button class="danger" (click)="confirmDelete()">Delete</button>
|
|
2608
|
-
<button (click)="cancelDelete()">Cancel</button>
|
|
2609
|
-
</div>
|
|
2610
|
-
</div>
|
|
2611
|
-
</div>
|
|
2612
|
-
|
|
2613
|
-
<div class="delete-overlay" *ngIf="replaceConfirmVisible">
|
|
2614
|
-
<div class="delete-dialog">
|
|
2615
|
-
<div class="delete-text">{{ replaceConfirmText }}</div>
|
|
2616
|
-
<div class="delete-buttons">
|
|
2617
|
-
<button class="danger" (click)="confirmReplace()">Replace</button>
|
|
2618
|
-
<button (click)="cancelReplace()">Cancel</button>
|
|
2619
|
-
</div>
|
|
2620
|
-
</div>
|
|
2621
|
-
</div>
|
|
2622
|
-
|
|
2623
|
-
<div class="delete-overlay" *ngIf="inputDialogVisible">
|
|
2624
|
-
<div class="delete-dialog" (click)="$event.stopPropagation()">
|
|
2625
|
-
<div class="delete-text">{{ inputDialogTitle }}</div>
|
|
2626
|
-
<input
|
|
2627
|
-
class="dialog-input"
|
|
2628
|
-
[(ngModel)]="inputDialogValue"
|
|
2629
|
-
[placeholder]="inputDialogPlaceholder"
|
|
2630
|
-
(keyup.enter)="confirmInputDialog()"
|
|
2631
|
-
/>
|
|
2632
|
-
<div class="delete-buttons">
|
|
2633
|
-
<button class="danger" (click)="confirmInputDialog()" [disabled]="!inputDialogValue.trim()">OK</button>
|
|
2634
|
-
<button (click)="cancelInputDialog()">Cancel</button>
|
|
2635
|
-
</div>
|
|
2636
|
-
</div>
|
|
2637
|
-
</div>
|
|
2638
|
-
|
|
2639
|
-
<div
|
|
2640
|
-
class="local-menu"
|
|
2641
|
-
*ngIf="localMenuVisible"
|
|
2642
|
-
[style.left.px]="localMenuX"
|
|
2643
|
-
[style.top.px]="localMenuY"
|
|
2644
|
-
(click)="$event.stopPropagation()"
|
|
2645
|
-
>
|
|
2646
|
-
<div class="local-menu-item" *ngFor="let item of localMenuItems" (click)="onLocalMenuItemClick(item)">
|
|
2647
|
-
{{ item.label }}
|
|
2648
|
-
</div>
|
|
2649
|
-
</div>
|
|
2650
|
-
</div>
|
|
2548
|
+
template: `
|
|
2549
|
+
<div class="sftp-root" tabindex="0" (keydown)="onKeyDown($event)">
|
|
2550
|
+
<div class="top-profiles" *ngIf="profile || recentProfiles.length">
|
|
2551
|
+
<div class="current" *ngIf="profile">
|
|
2552
|
+
<span class="label">Device:</span>
|
|
2553
|
+
<span class="value">{{ getProfileLabel(profile) }}</span>
|
|
2554
|
+
</div>
|
|
2555
|
+
<div class="recent" *ngIf="recentProfiles.length">
|
|
2556
|
+
<span class="label">Recent:</span>
|
|
2557
|
+
<button
|
|
2558
|
+
class="profile-chip"
|
|
2559
|
+
*ngFor="let p of recentProfiles"
|
|
2560
|
+
(click)="launchProfileFromSFTP(p)"
|
|
2561
|
+
>
|
|
2562
|
+
{{ getProfileLabel(p) }}
|
|
2563
|
+
</button>
|
|
2564
|
+
</div>
|
|
2565
|
+
</div>
|
|
2566
|
+
<div class="sftp-body">
|
|
2567
|
+
<div class="pane">
|
|
2568
|
+
<div class="pane-title">
|
|
2569
|
+
<div class="pane-label">Local</div>
|
|
2570
|
+
<div class="pane-path">
|
|
2571
|
+
<input
|
|
2572
|
+
[(ngModel)]="localPathInput"
|
|
2573
|
+
(keyup.enter)="goToLocalPathInput()"
|
|
2574
|
+
/>
|
|
2575
|
+
</div>
|
|
2576
|
+
<div class="pane-actions">
|
|
2577
|
+
<select class="path-preset" (change)="onLocalPresetChange($event.target.value)">
|
|
2578
|
+
<option value="">Go to…</option>
|
|
2579
|
+
<option *ngFor="let p of localPathPresets" [value]="p.id">
|
|
2580
|
+
{{ p.label }}
|
|
2581
|
+
</option>
|
|
2582
|
+
</select>
|
|
2583
|
+
<button
|
|
2584
|
+
class="fav-toggle"
|
|
2585
|
+
[class.active]="isCurrentFavorite()"
|
|
2586
|
+
(click)="toggleCurrentFavorite()"
|
|
2587
|
+
title="Toggle favorite for this path"
|
|
2588
|
+
>
|
|
2589
|
+
★
|
|
2590
|
+
</button>
|
|
2591
|
+
<select class="path-favorite" (change)="onLocalFavoriteSelect($event.target.value)">
|
|
2592
|
+
<option value="">Favorites…</option>
|
|
2593
|
+
<option *ngFor="let f of localFavorites" [value]="f.id">
|
|
2594
|
+
{{ f.label }}
|
|
2595
|
+
</option>
|
|
2596
|
+
</select>
|
|
2597
|
+
<button (click)="localUp()" [disabled]="!canLocalUp()">Up</button>
|
|
2598
|
+
<button (click)="goToLocalPathInput()">Go</button>
|
|
2599
|
+
<button (click)="refreshLocal()">Refresh</button>
|
|
2600
|
+
</div>
|
|
2601
|
+
</div>
|
|
2602
|
+
<div class="pane-filters">
|
|
2603
|
+
<div class="breadcrumbs">
|
|
2604
|
+
<ng-container *ngFor="let part of getLocalBreadcrumbs(); let i = index; let last = last">
|
|
2605
|
+
<button
|
|
2606
|
+
class="crumb-button"
|
|
2607
|
+
(click)="navigateLocalBreadcrumb(i)"
|
|
2608
|
+
(contextmenu)="onLocalBreadcrumbContextMenu(i, $event)"
|
|
2609
|
+
>
|
|
2610
|
+
{{ part.label }}
|
|
2611
|
+
</button>
|
|
2612
|
+
<span class="crumb-separator" *ngIf="!last">›</span>
|
|
2613
|
+
</ng-container>
|
|
2614
|
+
</div>
|
|
2615
|
+
<input [(ngModel)]="localFilter" placeholder="Filter files..." />
|
|
2616
|
+
<label class="show-hidden-toggle">
|
|
2617
|
+
<input type="checkbox" [(ngModel)]="showHiddenLocal" />
|
|
2618
|
+
<span>Show hidden</span>
|
|
2619
|
+
</label>
|
|
2620
|
+
</div>
|
|
2621
|
+
<div class="pane-list"
|
|
2622
|
+
(dragover)="onDragOver($event)"
|
|
2623
|
+
(drop)="onDropOnLocal($event)"
|
|
2624
|
+
>
|
|
2625
|
+
<div class="entry header">
|
|
2626
|
+
<span class="icon"></span>
|
|
2627
|
+
<span class="name sortable" (click)="setLocalSort('name')">Name</span>
|
|
2628
|
+
<span class="size sortable" (click)="setLocalSort('size')">Size</span>
|
|
2629
|
+
<span class="date sortable" (click)="setLocalSort('modified')">Modified</span>
|
|
2630
|
+
</div>
|
|
2631
|
+
<div
|
|
2632
|
+
class="entry"
|
|
2633
|
+
*ngIf="canLocalUp()"
|
|
2634
|
+
(dblclick)="localUp()"
|
|
2635
|
+
>
|
|
2636
|
+
<span class="icon">⬆</span>
|
|
2637
|
+
<span class="name">Go up</span>
|
|
2638
|
+
<span class="size"></span>
|
|
2639
|
+
<span class="date"></span>
|
|
2640
|
+
</div>
|
|
2641
|
+
<div
|
|
2642
|
+
class="entry"
|
|
2643
|
+
*ngFor="let e of getFilteredLocalEntries()"
|
|
2644
|
+
(click)="selectLocal(e, $event)"
|
|
2645
|
+
(dblclick)="openLocal(e)"
|
|
2646
|
+
(mousedown)="onLocalMouseDown(e, $event)"
|
|
2647
|
+
(contextmenu)="onLocalContextMenu(e, $event)"
|
|
2648
|
+
(dragover)="onLocalEntryDragOver(e, $event)"
|
|
2649
|
+
(drop)="onLocalEntryDrop(e, $event)"
|
|
2650
|
+
[class.drop-target]="localDropActive"
|
|
2651
|
+
[class.selected]="isLocalSelected(e)"
|
|
2652
|
+
[draggable]="true"
|
|
2653
|
+
(dragstart)="onDragStartLocal($event, e)"
|
|
2654
|
+
>
|
|
2655
|
+
<span class="icon">{{ e.isDirectory ? '📁' : '📄' }}</span>
|
|
2656
|
+
<span class="name">{{ e.name }}</span>
|
|
2657
|
+
<span class="size">{{ getLocalSizeDisplay(e) }}</span>
|
|
2658
|
+
<span class="date">{{ e.mtimeMs ? (e.mtimeMs | date:'yyyy-MM-dd HH:mm') : '' }}</span>
|
|
2659
|
+
</div>
|
|
2660
|
+
</div>
|
|
2661
|
+
<div class="pane-actions-bar">
|
|
2662
|
+
<div class="selection" *ngIf="selectedLocal.length">
|
|
2663
|
+
Selected: {{ selectedLocal.length === 1 ? selectedLocal[0].name : (selectedLocal.length + ' items') }}
|
|
2664
|
+
</div>
|
|
2665
|
+
<div class="action-inputs">
|
|
2666
|
+
<input [(ngModel)]="localActionName" placeholder="Name / new name" />
|
|
2667
|
+
<input [(ngModel)]="localActionPerms" placeholder="Perms (e.g. 755)" />
|
|
2668
|
+
</div>
|
|
2669
|
+
<div class="action-buttons">
|
|
2670
|
+
<button (click)="localRename()" [disabled]="selectedLocal.length !== 1">Rename</button>
|
|
2671
|
+
<button (click)="refreshLocal()">Refresh</button>
|
|
2672
|
+
<button (click)="localDelete()" [disabled]="!selectedLocal.length">Delete</button>
|
|
2673
|
+
<button (click)="localNewFolder()">New Folder</button>
|
|
2674
|
+
<button (click)="localEditPermissions()" [disabled]="selectedLocal.length !== 1 || !localActionPerms">Edit Permissions</button>
|
|
2675
|
+
<button (click)="localShowSize()" [disabled]="selectedLocal.length !== 1 || !selectedLocal[0].isDirectory">Show Size</button>
|
|
2676
|
+
</div>
|
|
2677
|
+
</div>
|
|
2678
|
+
</div>
|
|
2679
|
+
|
|
2680
|
+
<div class="pane">
|
|
2681
|
+
<div class="pane-title">
|
|
2682
|
+
<div class="pane-label">
|
|
2683
|
+
Remote
|
|
2684
|
+
<span *ngIf="connected && profile?.options?.host" class="pane-sub">
|
|
2685
|
+
— {{ profile.options.host }}
|
|
2686
|
+
</span>
|
|
2687
|
+
</div>
|
|
2688
|
+
<div class="pane-path">
|
|
2689
|
+
<input
|
|
2690
|
+
[(ngModel)]="remotePathInput"
|
|
2691
|
+
(keyup.enter)="goToRemotePathInput()"
|
|
2692
|
+
[disabled]="!connected"
|
|
2693
|
+
/>
|
|
2694
|
+
</div>
|
|
2695
|
+
<div class="pane-actions">
|
|
2696
|
+
<button (click)="remoteUp()" [disabled]="!connected || !canRemoteUp()">Up</button>
|
|
2697
|
+
<button (click)="goToRemotePathInput()" [disabled]="!connected">Go</button>
|
|
2698
|
+
<button (click)="refreshRemote()" [disabled]="!connected">Refresh</button>
|
|
2699
|
+
</div>
|
|
2700
|
+
</div>
|
|
2701
|
+
<div class="pane-filters">
|
|
2702
|
+
<div class="breadcrumbs" *ngIf="connected">
|
|
2703
|
+
<ng-container *ngFor="let part of getRemoteBreadcrumbs(); let i = index; let last = last">
|
|
2704
|
+
<button
|
|
2705
|
+
class="crumb-button"
|
|
2706
|
+
(click)="navigateRemoteBreadcrumb(i)"
|
|
2707
|
+
>
|
|
2708
|
+
{{ part.label }}
|
|
2709
|
+
</button>
|
|
2710
|
+
<span class="crumb-separator" *ngIf="!last">›</span>
|
|
2711
|
+
</ng-container>
|
|
2712
|
+
</div>
|
|
2713
|
+
<input [(ngModel)]="remoteFilter" placeholder="Filter files..." />
|
|
2714
|
+
<label class="show-hidden-toggle">
|
|
2715
|
+
<input type="checkbox" [(ngModel)]="showHiddenRemote" />
|
|
2716
|
+
<span>Show hidden</span>
|
|
2717
|
+
</label>
|
|
2718
|
+
</div>
|
|
2719
|
+
<div class="pane-list"
|
|
2720
|
+
(dragover)="onDragOver($event)"
|
|
2721
|
+
(drop)="onDropOnRemote($event)"
|
|
2722
|
+
>
|
|
2723
|
+
<div class="entry dim" *ngIf="!connected">
|
|
2724
|
+
<span class="name">Not connected</span>
|
|
2725
|
+
</div>
|
|
2726
|
+
<div class="entry header" *ngIf="connected">
|
|
2727
|
+
<span class="icon"></span>
|
|
2728
|
+
<span class="name sortable" (click)="setRemoteSort('name')">Name</span>
|
|
2729
|
+
<span class="size sortable" (click)="setRemoteSort('size')">Size</span>
|
|
2730
|
+
<span class="date sortable" (click)="setRemoteSort('modified')">Modified</span>
|
|
2731
|
+
</div>
|
|
2732
|
+
<div
|
|
2733
|
+
class="entry"
|
|
2734
|
+
*ngIf="connected && canRemoteUp()"
|
|
2735
|
+
(dblclick)="remoteUp()"
|
|
2736
|
+
>
|
|
2737
|
+
<span class="icon">⬆</span>
|
|
2738
|
+
<span class="name">Go up</span>
|
|
2739
|
+
<span class="size"></span>
|
|
2740
|
+
<span class="date"></span>
|
|
2741
|
+
</div>
|
|
2742
|
+
<div
|
|
2743
|
+
class="entry"
|
|
2744
|
+
*ngFor="let e of getFilteredRemoteEntries()"
|
|
2745
|
+
(click)="selectRemote(e, $event)"
|
|
2746
|
+
(dblclick)="openRemote(e)"
|
|
2747
|
+
(mousedown)="onRemoteMouseDown(e, $event)"
|
|
2748
|
+
(contextmenu)="onRemoteContextMenu(e, $event)"
|
|
2749
|
+
(dragover)="onRemoteEntryDragOver(e, $event)"
|
|
2750
|
+
(drop)="onRemoteEntryDrop(e, $event)"
|
|
2751
|
+
[class.drop-target]="remoteDropActive"
|
|
2752
|
+
[class.selected]="isRemoteSelected(e)"
|
|
2753
|
+
[draggable]="connected"
|
|
2754
|
+
(dragstart)="onDragStartRemote($event, e)"
|
|
2755
|
+
>
|
|
2756
|
+
<span class="icon">{{ e.isDirectory ? '📁' : '📄' }}</span>
|
|
2757
|
+
<span class="name">{{ e.name }}</span>
|
|
2758
|
+
<span class="size">{{ getRemoteSizeDisplay(e) }}</span>
|
|
2759
|
+
<span class="date">{{ e.modified | date:'yyyy-MM-dd HH:mm' }}</span>
|
|
2760
|
+
</div>
|
|
2761
|
+
</div>
|
|
2762
|
+
<div class="pane-actions-bar">
|
|
2763
|
+
<div class="selection" *ngIf="selectedRemote.length">
|
|
2764
|
+
Selected: {{ selectedRemote.length === 1 ? selectedRemote[0].name : (selectedRemote.length + ' items') }}
|
|
2765
|
+
</div>
|
|
2766
|
+
<div class="action-inputs">
|
|
2767
|
+
<input [(ngModel)]="remoteActionName" placeholder="Name / new name" />
|
|
2768
|
+
<input [(ngModel)]="remoteActionPerms" placeholder="Perms (e.g. 755)" />
|
|
2769
|
+
</div>
|
|
2770
|
+
<div class="action-buttons">
|
|
2771
|
+
<button (click)="remoteRename()" [disabled]="selectedRemote.length !== 1">Rename</button>
|
|
2772
|
+
<button (click)="refreshRemote()" [disabled]="!connected">Refresh</button>
|
|
2773
|
+
<button (click)="remoteDelete()" [disabled]="!selectedRemote.length">Delete</button>
|
|
2774
|
+
<button (click)="remoteNewFolder()" [disabled]="!connected">New Folder</button>
|
|
2775
|
+
<button (click)="remoteEditPermissions()" [disabled]="selectedRemote.length !== 1 || !remoteActionPerms">Edit Permissions</button>
|
|
2776
|
+
<button (click)="remoteShowSize()" [disabled]="selectedRemote.length !== 1 || !selectedRemote[0].isDirectory">Show Size</button>
|
|
2777
|
+
<button (click)="remoteDownload()" [disabled]="!selectedRemote.length">Download</button>
|
|
2778
|
+
</div>
|
|
2779
|
+
</div>
|
|
2780
|
+
</div>
|
|
2781
|
+
</div>
|
|
2782
|
+
<div class="sftp-transfers" *ngIf="transfers.length">
|
|
2783
|
+
<div class="transfer" *ngFor="let t of transfers">
|
|
2784
|
+
<div class="transfer-main">
|
|
2785
|
+
<div class="transfer-title">
|
|
2786
|
+
<span class="direction">{{ t.direction === 'upload' ? 'Upload' : 'Download' }}</span>
|
|
2787
|
+
<span class="name">{{ t.name }}</span>
|
|
2788
|
+
</div>
|
|
2789
|
+
<div class="transfer-path">
|
|
2790
|
+
<span class="label">Remote:</span>
|
|
2791
|
+
<span class="value">{{ t.remotePath }}</span>
|
|
2792
|
+
</div>
|
|
2793
|
+
<div class="transfer-path">
|
|
2794
|
+
<span class="label">Local:</span>
|
|
2795
|
+
<span class="value">{{ t.localPath }}</span>
|
|
2796
|
+
</div>
|
|
2797
|
+
<div class="bar">
|
|
2798
|
+
<div class="fill" [style.width.%]="getTransferProgress(t.transfer)"></div>
|
|
2799
|
+
</div>
|
|
2800
|
+
</div>
|
|
2801
|
+
<div class="transfer-stats">
|
|
2802
|
+
<div class="percent">{{ getTransferProgress(t.transfer) | number:'1.0-0' }}%</div>
|
|
2803
|
+
<div class="speed">{{ formatSpeed(t.transfer.getSpeed()) }}</div>
|
|
2804
|
+
<button class="btn-cancel" (click)="cancelTransfer(t)" [disabled]="t.transfer.isComplete() || t.transfer.isCancelled()">Cancel</button>
|
|
2805
|
+
</div>
|
|
2806
|
+
</div>
|
|
2807
|
+
</div>
|
|
2808
|
+
|
|
2809
|
+
<div class="delete-overlay" *ngIf="deleteConfirmVisible">
|
|
2810
|
+
<div class="delete-dialog">
|
|
2811
|
+
<div class="delete-text">{{ deleteConfirmText }}</div>
|
|
2812
|
+
<div class="delete-buttons">
|
|
2813
|
+
<button class="danger" (click)="confirmDelete()">Delete</button>
|
|
2814
|
+
<button (click)="cancelDelete()">Cancel</button>
|
|
2815
|
+
</div>
|
|
2816
|
+
</div>
|
|
2817
|
+
</div>
|
|
2818
|
+
|
|
2819
|
+
<div class="delete-overlay" *ngIf="replaceConfirmVisible">
|
|
2820
|
+
<div class="delete-dialog">
|
|
2821
|
+
<div class="delete-text">{{ replaceConfirmText }}</div>
|
|
2822
|
+
<div class="delete-buttons">
|
|
2823
|
+
<button class="danger" (click)="confirmReplace()">Replace</button>
|
|
2824
|
+
<button (click)="cancelReplace()">Cancel</button>
|
|
2825
|
+
</div>
|
|
2826
|
+
</div>
|
|
2827
|
+
</div>
|
|
2828
|
+
|
|
2829
|
+
<div class="delete-overlay" *ngIf="inputDialogVisible">
|
|
2830
|
+
<div class="delete-dialog" (click)="$event.stopPropagation()">
|
|
2831
|
+
<div class="delete-text">{{ inputDialogTitle }}</div>
|
|
2832
|
+
<input
|
|
2833
|
+
class="dialog-input"
|
|
2834
|
+
[(ngModel)]="inputDialogValue"
|
|
2835
|
+
[placeholder]="inputDialogPlaceholder"
|
|
2836
|
+
(keyup.enter)="confirmInputDialog()"
|
|
2837
|
+
/>
|
|
2838
|
+
<div class="delete-buttons">
|
|
2839
|
+
<button class="danger" (click)="confirmInputDialog()" [disabled]="!inputDialogValue.trim()">OK</button>
|
|
2840
|
+
<button (click)="cancelInputDialog()">Cancel</button>
|
|
2841
|
+
</div>
|
|
2842
|
+
</div>
|
|
2843
|
+
</div>
|
|
2844
|
+
|
|
2845
|
+
<div
|
|
2846
|
+
class="local-menu"
|
|
2847
|
+
*ngIf="localMenuVisible"
|
|
2848
|
+
[style.left.px]="localMenuX"
|
|
2849
|
+
[style.top.px]="localMenuY"
|
|
2850
|
+
(click)="$event.stopPropagation()"
|
|
2851
|
+
>
|
|
2852
|
+
<div class="local-menu-item" *ngFor="let item of localMenuItems" (click)="onLocalMenuItemClick(item)">
|
|
2853
|
+
{{ item.label }}
|
|
2854
|
+
</div>
|
|
2855
|
+
</div>
|
|
2856
|
+
</div>
|
|
2651
2857
|
`,
|
|
2652
|
-
styles: [`
|
|
2653
|
-
.sftp-root { display: flex; flex-direction: column; height: 100%; padding: 10px; gap: 10px; position: relative; }
|
|
2654
|
-
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; }
|
|
2655
|
-
button:disabled { opacity: 0.5; cursor: default; }
|
|
2656
|
-
.top-profiles { display: flex; justify-content: space-between; align-items: center; padding: 4px 8px 8px; gap: 12px; font-size: 11px; opacity: 0.9; }
|
|
2657
|
-
.top-profiles .current .label,
|
|
2658
|
-
.top-profiles .recent .label { text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; margin-right: 4px; }
|
|
2659
|
-
.top-profiles .value { font-weight: 600; }
|
|
2660
|
-
.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; }
|
|
2661
|
-
.top-profiles .profile-chip:hover { background: rgba(255,255,255,0.12); }
|
|
2662
|
-
.sftp-body { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; flex: 1; min-height: 0; }
|
|
2663
|
-
.pane { display: flex; flex-direction: column; border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; overflow: hidden; min-height: 0; }
|
|
2664
|
-
.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); }
|
|
2665
|
-
.pane-label { font-weight: 600; display: flex; align-items: baseline; gap: 6px; }
|
|
2666
|
-
.pane-sub { font-weight: 400; font-size: 11px; opacity: 0.75; }
|
|
2667
|
-
.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; }
|
|
2668
|
-
.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; }
|
|
2669
|
-
.pane-actions { display: flex; gap: 8px; align-items: center; }
|
|
2670
|
-
.pane-actions .path-preset,
|
|
2671
|
-
.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; }
|
|
2672
|
-
.pane-actions .path-preset option { background: #151515; color: #f5f5f5; }
|
|
2673
|
-
.pane-actions .path-favorite option { background: #151515; color: #f5f5f5; }
|
|
2674
|
-
.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; }
|
|
2675
|
-
.pane-actions .fav-toggle.active { background: rgba(255,215,0,0.2); border-color: rgba(255,215,0,0.6); color: #ffd700; }
|
|
2676
|
-
.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); }
|
|
2677
|
-
.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; }
|
|
2678
|
-
.show-hidden-toggle { display: flex; align-items: center; gap: 4px; font-size: 11px; opacity: 0.8; white-space: nowrap; }
|
|
2679
|
-
.show-hidden-toggle input[type="checkbox"] { margin: 0; }
|
|
2680
|
-
.breadcrumbs { display: flex; flex-wrap: wrap; gap: 4px; font-size: 11px; opacity: 0.9; align-items: center; }
|
|
2681
|
-
.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; }
|
|
2682
|
-
.crumb-button:hover { background: rgba(255,255,255,0.10); }
|
|
2683
|
-
.crumb-separator { opacity: 0.6; }
|
|
2684
|
-
.pane-list { flex: 1; overflow: auto; padding: 4px; }
|
|
2685
|
-
.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; }
|
|
2686
|
-
.entry:hover { background: rgba(255,255,255,0.06); }
|
|
2687
|
-
.entry.drop-target { outline: 1px dashed rgba(255,255,255,0.35); background: rgba(80, 160, 255, 0.10); }
|
|
2688
|
-
.entry.dim { opacity: 0.7; }
|
|
2689
|
-
.icon { text-align: center; opacity: 0.85; }
|
|
2690
|
-
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2691
|
-
.size { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; text-align: right; opacity: 0.8; }
|
|
2692
|
-
.date { font-size: 11px; opacity: 0.75; text-align: right; white-space: nowrap; }
|
|
2693
|
-
.entry.header { font-weight: 600; opacity: 0.9; background: rgba(255,255,255,0.02); }
|
|
2694
|
-
.sortable { cursor: pointer; }
|
|
2695
|
-
.entry.selected { background: rgba(80,160,255,0.18); }
|
|
2696
|
-
.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); }
|
|
2697
|
-
.pane-actions-bar .selection { font-size: 11px; opacity: 0.85; }
|
|
2698
|
-
.pane-actions-bar .action-inputs { display: flex; gap: 6px; }
|
|
2699
|
-
.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; }
|
|
2700
|
-
.pane-actions-bar .action-buttons { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
|
2701
|
-
.sftp-transfers { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; max-height: 120px; overflow-y: auto; }
|
|
2702
|
-
.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; }
|
|
2703
|
-
.transfer-title { display: flex; gap: 6px; align-items: baseline; margin-bottom: 2px; }
|
|
2704
|
-
.transfer-title .direction { text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.7; font-weight: 600; font-size: 10px; }
|
|
2705
|
-
.transfer-title .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2706
|
-
.transfer-path { display: flex; gap: 4px; opacity: 0.75; }
|
|
2707
|
-
.transfer-path .label { min-width: 48px; }
|
|
2708
|
-
.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; }
|
|
2709
|
-
.bar { position: relative; height: 4px; border-radius: 999px; background: rgba(255,255,255,0.07); margin-top: 4px; overflow: hidden; }
|
|
2710
|
-
.bar .fill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: inherit; background: linear-gradient(90deg, #4dabff, #78ffce); transition: width 0.15s linear; }
|
|
2711
|
-
.transfer-stats { display: flex; flex-direction: column; justify-content: center; align-items: flex-end; gap: 4px; opacity: 0.8; }
|
|
2712
|
-
.transfer-stats .percent { font-weight: 600; }
|
|
2713
|
-
.transfer-stats .speed { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
|
2714
|
-
.btn-cancel { padding: 2px 6px; font-size: 10px; border-radius: 999px; }
|
|
2715
|
-
.delete-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; z-index: 20; }
|
|
2716
|
-
.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; }
|
|
2717
|
-
.delete-text { font-size: 13px; }
|
|
2718
|
-
.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; }
|
|
2719
|
-
.delete-buttons { display: flex; justify-content: flex-end; gap: 8px; }
|
|
2720
|
-
.delete-buttons .danger { background: rgba(255,80,80,0.85); border-color: rgba(255,120,120,0.85); }
|
|
2721
|
-
.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); }
|
|
2722
|
-
.local-menu-item { padding: 6px 12px; font-size: 12px; cursor: pointer; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
|
|
2723
|
-
.local-menu-item:hover { background: linear-gradient(90deg, rgba(120,200,255,0.24), rgba(120,255,206,0.15)); }
|
|
2858
|
+
styles: [`
|
|
2859
|
+
.sftp-root { display: flex; flex-direction: column; height: 100%; padding: 10px; gap: 10px; position: relative; }
|
|
2860
|
+
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; }
|
|
2861
|
+
button:disabled { opacity: 0.5; cursor: default; }
|
|
2862
|
+
.top-profiles { display: flex; justify-content: space-between; align-items: center; padding: 4px 8px 8px; gap: 12px; font-size: 11px; opacity: 0.9; }
|
|
2863
|
+
.top-profiles .current .label,
|
|
2864
|
+
.top-profiles .recent .label { text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; margin-right: 4px; }
|
|
2865
|
+
.top-profiles .value { font-weight: 600; }
|
|
2866
|
+
.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; }
|
|
2867
|
+
.top-profiles .profile-chip:hover { background: rgba(255,255,255,0.12); }
|
|
2868
|
+
.sftp-body { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; flex: 1; min-height: 0; }
|
|
2869
|
+
.pane { display: flex; flex-direction: column; border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; overflow: hidden; min-height: 0; }
|
|
2870
|
+
.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); }
|
|
2871
|
+
.pane-label { font-weight: 600; display: flex; align-items: baseline; gap: 6px; }
|
|
2872
|
+
.pane-sub { font-weight: 400; font-size: 11px; opacity: 0.75; }
|
|
2873
|
+
.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; }
|
|
2874
|
+
.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; }
|
|
2875
|
+
.pane-actions { display: flex; gap: 8px; align-items: center; }
|
|
2876
|
+
.pane-actions .path-preset,
|
|
2877
|
+
.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; }
|
|
2878
|
+
.pane-actions .path-preset option { background: #151515; color: #f5f5f5; }
|
|
2879
|
+
.pane-actions .path-favorite option { background: #151515; color: #f5f5f5; }
|
|
2880
|
+
.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; }
|
|
2881
|
+
.pane-actions .fav-toggle.active { background: rgba(255,215,0,0.2); border-color: rgba(255,215,0,0.6); color: #ffd700; }
|
|
2882
|
+
.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); }
|
|
2883
|
+
.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; }
|
|
2884
|
+
.show-hidden-toggle { display: flex; align-items: center; gap: 4px; font-size: 11px; opacity: 0.8; white-space: nowrap; }
|
|
2885
|
+
.show-hidden-toggle input[type="checkbox"] { margin: 0; }
|
|
2886
|
+
.breadcrumbs { display: flex; flex-wrap: wrap; gap: 4px; font-size: 11px; opacity: 0.9; align-items: center; }
|
|
2887
|
+
.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; }
|
|
2888
|
+
.crumb-button:hover { background: rgba(255,255,255,0.10); }
|
|
2889
|
+
.crumb-separator { opacity: 0.6; }
|
|
2890
|
+
.pane-list { flex: 1; overflow: auto; padding: 4px; }
|
|
2891
|
+
.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; }
|
|
2892
|
+
.entry:hover { background: rgba(255,255,255,0.06); }
|
|
2893
|
+
.entry.drop-target { outline: 1px dashed rgba(255,255,255,0.35); background: rgba(80, 160, 255, 0.10); }
|
|
2894
|
+
.entry.dim { opacity: 0.7; }
|
|
2895
|
+
.icon { text-align: center; opacity: 0.85; }
|
|
2896
|
+
.name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2897
|
+
.size { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; text-align: right; opacity: 0.8; }
|
|
2898
|
+
.date { font-size: 11px; opacity: 0.75; text-align: right; white-space: nowrap; }
|
|
2899
|
+
.entry.header { font-weight: 600; opacity: 0.9; background: rgba(255,255,255,0.02); }
|
|
2900
|
+
.sortable { cursor: pointer; }
|
|
2901
|
+
.entry.selected { background: rgba(80,160,255,0.18); }
|
|
2902
|
+
.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); }
|
|
2903
|
+
.pane-actions-bar .selection { font-size: 11px; opacity: 0.85; }
|
|
2904
|
+
.pane-actions-bar .action-inputs { display: flex; gap: 6px; }
|
|
2905
|
+
.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; }
|
|
2906
|
+
.pane-actions-bar .action-buttons { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; }
|
|
2907
|
+
.sftp-transfers { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; max-height: 120px; overflow-y: auto; }
|
|
2908
|
+
.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; }
|
|
2909
|
+
.transfer-title { display: flex; gap: 6px; align-items: baseline; margin-bottom: 2px; }
|
|
2910
|
+
.transfer-title .direction { text-transform: uppercase; letter-spacing: 0.04em; opacity: 0.7; font-weight: 600; font-size: 10px; }
|
|
2911
|
+
.transfer-title .name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
2912
|
+
.transfer-path { display: flex; gap: 4px; opacity: 0.75; }
|
|
2913
|
+
.transfer-path .label { min-width: 48px; }
|
|
2914
|
+
.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; }
|
|
2915
|
+
.bar { position: relative; height: 4px; border-radius: 999px; background: rgba(255,255,255,0.07); margin-top: 4px; overflow: hidden; }
|
|
2916
|
+
.bar .fill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: inherit; background: linear-gradient(90deg, #4dabff, #78ffce); transition: width 0.15s linear; }
|
|
2917
|
+
.transfer-stats { display: flex; flex-direction: column; justify-content: center; align-items: flex-end; gap: 4px; opacity: 0.8; }
|
|
2918
|
+
.transfer-stats .percent { font-weight: 600; }
|
|
2919
|
+
.transfer-stats .speed { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
|
2920
|
+
.btn-cancel { padding: 2px 6px; font-size: 10px; border-radius: 999px; }
|
|
2921
|
+
.delete-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; z-index: 20; }
|
|
2922
|
+
.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; }
|
|
2923
|
+
.delete-text { font-size: 13px; }
|
|
2924
|
+
.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; }
|
|
2925
|
+
.delete-buttons { display: flex; justify-content: flex-end; gap: 8px; }
|
|
2926
|
+
.delete-buttons .danger { background: rgba(255,80,80,0.85); border-color: rgba(255,120,120,0.85); }
|
|
2927
|
+
.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); }
|
|
2928
|
+
.local-menu-item { padding: 6px 12px; font-size: 12px; cursor: pointer; white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
|
|
2929
|
+
.local-menu-item:hover { background: linear-gradient(90deg, rgba(120,200,255,0.24), rgba(120,255,206,0.15)); }
|
|
2724
2930
|
`],
|
|
2725
2931
|
}),
|
|
2726
2932
|
__metadata("design:paramtypes", [_angular_core__WEBPACK_IMPORTED_MODULE_5__.Injector,
|
|
@@ -2731,6 +2937,57 @@ SftpManagerTabComponent = __decorate([
|
|
|
2731
2937
|
|
|
2732
2938
|
|
|
2733
2939
|
|
|
2940
|
+
/***/ },
|
|
2941
|
+
|
|
2942
|
+
/***/ "./src/sftp-recovery-provider.ts"
|
|
2943
|
+
/*!***************************************!*\
|
|
2944
|
+
!*** ./src/sftp-recovery-provider.ts ***!
|
|
2945
|
+
\***************************************/
|
|
2946
|
+
(__unused_webpack_module, __webpack_exports__, __webpack_require__) {
|
|
2947
|
+
|
|
2948
|
+
__webpack_require__.r(__webpack_exports__);
|
|
2949
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
2950
|
+
/* harmony export */ SFTP_RECOVERY_TYPE: () => (/* binding */ SFTP_RECOVERY_TYPE),
|
|
2951
|
+
/* harmony export */ SftpTabRecoveryProvider: () => (/* binding */ SftpTabRecoveryProvider)
|
|
2952
|
+
/* harmony export */ });
|
|
2953
|
+
/* harmony import */ var _angular_core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @angular/core */ "@angular/core");
|
|
2954
|
+
/* harmony import */ var _angular_core__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(_angular_core__WEBPACK_IMPORTED_MODULE_0__);
|
|
2955
|
+
/* harmony import */ var tabby_core__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! tabby-core */ "tabby-core");
|
|
2956
|
+
/* harmony import */ var tabby_core__WEBPACK_IMPORTED_MODULE_1___default = /*#__PURE__*/__webpack_require__.n(tabby_core__WEBPACK_IMPORTED_MODULE_1__);
|
|
2957
|
+
/* harmony import */ var _sftp_manager_tab_component__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./sftp-manager-tab.component */ "./src/sftp-manager-tab.component.ts");
|
|
2958
|
+
var __decorate = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
|
|
2959
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
2960
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
2961
|
+
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;
|
|
2962
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
2963
|
+
};
|
|
2964
|
+
|
|
2965
|
+
|
|
2966
|
+
|
|
2967
|
+
const SFTP_RECOVERY_TYPE = 'tabby-sftp-ui:sftp-tab';
|
|
2968
|
+
let SftpTabRecoveryProvider = class SftpTabRecoveryProvider extends tabby_core__WEBPACK_IMPORTED_MODULE_1__.TabRecoveryProvider {
|
|
2969
|
+
async applicableTo(recoveryToken) {
|
|
2970
|
+
return recoveryToken?.type === SFTP_RECOVERY_TYPE;
|
|
2971
|
+
}
|
|
2972
|
+
async recover(_recoveryToken) {
|
|
2973
|
+
// Restore a stub tab that immediately closes itself and its SplitTab wrapper.
|
|
2974
|
+
return {
|
|
2975
|
+
type: _sftp_manager_tab_component__WEBPACK_IMPORTED_MODULE_2__.SftpManagerTabComponent,
|
|
2976
|
+
inputs: {
|
|
2977
|
+
sshSession: null,
|
|
2978
|
+
profile: null,
|
|
2979
|
+
recoveredStub: true,
|
|
2980
|
+
},
|
|
2981
|
+
};
|
|
2982
|
+
}
|
|
2983
|
+
};
|
|
2984
|
+
SftpTabRecoveryProvider = __decorate([
|
|
2985
|
+
(0,_angular_core__WEBPACK_IMPORTED_MODULE_0__.Injectable)()
|
|
2986
|
+
], SftpTabRecoveryProvider);
|
|
2987
|
+
|
|
2988
|
+
|
|
2989
|
+
|
|
2990
|
+
|
|
2734
2991
|
/***/ },
|
|
2735
2992
|
|
|
2736
2993
|
/***/ "./src/sftp-terminal-decorator.ts"
|
|
@@ -2768,7 +3025,13 @@ let SftpTerminalDecorator = class SftpTerminalDecorator extends tabby_terminal__
|
|
|
2768
3025
|
attach(terminal) {
|
|
2769
3026
|
super.attach(terminal);
|
|
2770
3027
|
// Best-effort DOM injection: place button near the existing Reconnect button if present.
|
|
3028
|
+
// Only inject for SSH tabs (with a live sshSession property).
|
|
2771
3029
|
const tryInsert = () => {
|
|
3030
|
+
// Skip for local terminals (PowerShell, CMD, etc.) - only SSH tabs need this button.
|
|
3031
|
+
const sshSession = terminal.sshSession;
|
|
3032
|
+
if (!sshSession) {
|
|
3033
|
+
return false;
|
|
3034
|
+
}
|
|
2772
3035
|
try {
|
|
2773
3036
|
const host = terminal.element?.nativeElement ?? null;
|
|
2774
3037
|
if (!host) {
|
|
@@ -2905,6 +3168,7 @@ let SftpUiService = class SftpUiService {
|
|
|
2905
3168
|
inputs: {
|
|
2906
3169
|
sshSession,
|
|
2907
3170
|
profile,
|
|
3171
|
+
shellSession: sourceTab?.session ?? null,
|
|
2908
3172
|
},
|
|
2909
3173
|
});
|
|
2910
3174
|
tab.setTitle(`${baseTitle} + SFTP`);
|
|
@@ -3162,6 +3426,7 @@ __webpack_require__.r(__webpack_exports__);
|
|
|
3162
3426
|
/* harmony import */ var _sftp_hotkey__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(/*! ./sftp-hotkey */ "./src/sftp-hotkey.ts");
|
|
3163
3427
|
/* harmony import */ var _sftp_ui_service__WEBPACK_IMPORTED_MODULE_8__ = __webpack_require__(/*! ./sftp-ui.service */ "./src/sftp-ui.service.ts");
|
|
3164
3428
|
/* harmony import */ var _sftp_terminal_decorator__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(/*! ./sftp-terminal-decorator */ "./src/sftp-terminal-decorator.ts");
|
|
3429
|
+
/* harmony import */ var _sftp_recovery_provider__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(/*! ./sftp-recovery-provider */ "./src/sftp-recovery-provider.ts");
|
|
3165
3430
|
var __decorate = (undefined && undefined.__decorate) || function (decorators, target, key, desc) {
|
|
3166
3431
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3167
3432
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -3181,6 +3446,7 @@ var __metadata = (undefined && undefined.__metadata) || function (k, v) {
|
|
|
3181
3446
|
|
|
3182
3447
|
|
|
3183
3448
|
|
|
3449
|
+
|
|
3184
3450
|
let SftpUiModule = class SftpUiModule {
|
|
3185
3451
|
constructor(_) { }
|
|
3186
3452
|
};
|
|
@@ -3197,6 +3463,7 @@ SftpUiModule = __decorate([
|
|
|
3197
3463
|
{ provide: tabby_core__WEBPACK_IMPORTED_MODULE_3__.TabContextMenuItemProvider, useClass: _sftp_context_menu__WEBPACK_IMPORTED_MODULE_6__.SftpContextMenuProvider, multi: true },
|
|
3198
3464
|
{ provide: tabby_core__WEBPACK_IMPORTED_MODULE_3__.HotkeyProvider, useClass: _sftp_hotkey__WEBPACK_IMPORTED_MODULE_7__.SftpUiHotkeyProvider, multi: true },
|
|
3199
3465
|
{ provide: tabby_terminal__WEBPACK_IMPORTED_MODULE_4__.TerminalDecorator, useClass: _sftp_terminal_decorator__WEBPACK_IMPORTED_MODULE_9__.SftpTerminalDecorator, multi: true },
|
|
3466
|
+
{ provide: tabby_core__WEBPACK_IMPORTED_MODULE_3__.TabRecoveryProvider, useClass: _sftp_recovery_provider__WEBPACK_IMPORTED_MODULE_10__.SftpTabRecoveryProvider, multi: true },
|
|
3200
3467
|
_sftp_ui_service__WEBPACK_IMPORTED_MODULE_8__.SftpUiService,
|
|
3201
3468
|
],
|
|
3202
3469
|
}),
|