diff --git a/app/lib/window.ts b/app/lib/window.ts index 613e47978f..438f43421d 100644 --- a/app/lib/window.ts +++ b/app/lib/window.ts @@ -323,6 +323,9 @@ export class Window { this.window.on('show', () => { this.visible.next(true) this.send('host:window-shown') + try { + this.window.webContents.invalidate() + } catch {} }) this.window.on('hide', () => { @@ -373,6 +376,15 @@ export class Window { this.window.on('focus', () => { this.send('host:window-focused') + try { + this.window.webContents.invalidate() + } catch {} + }) + + this.window.on('restore', () => { + try { + this.window.webContents.invalidate() + } catch {} }) this.on('ready', () => { diff --git a/app/package.json b/app/package.json index 86e34e3e3f..8b86986a3f 100644 --- a/app/package.json +++ b/app/package.json @@ -63,7 +63,7 @@ "tabby-terminal": "*" }, "resolutions": { - "node-abi": "4.9.0", + "node-abi": "^3.0.0", "node-gyp": "^10.0.0", "nan": "2.22.2", "node-addon-api": "^8.3.0" diff --git a/app/yarn.lock b/app/yarn.lock index 38d4be643f..5ad916d89a 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -2981,12 +2981,12 @@ negotiator@^0.6.3: resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.4.tgz#777948e2452651c570b712dd01c23e262713fff7" integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w== -node-abi@4.9.0, node-abi@^3.3.0: - version "4.9.0" - resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-4.9.0.tgz#ca6dabf7991e54bf3ba6d8d32641e1b84f305263" - integrity sha512-0isb3h+AXUblx5Iv0mnYy2WsErH+dk2e9iXJXdKAtS076Q5hP+scQhp6P4tvDeVlOBlG3ROKvkpQHtbORllq2A== +node-abi@^3.0.0, node-abi@^3.3.0: + version "3.87.0" + resolved "https://registry.yarnpkg.com/node-abi/-/node-abi-3.87.0.tgz#423e28fea5c2f195fddd98acded9938c001ae6dd" + integrity sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ== dependencies: - semver "^7.6.3" + semver "^7.3.5" node-addon-api@3.1.0, node-addon-api@6.1.0, node-addon-api@7.1.0, node-addon-api@^3.0.2, node-addon-api@^3.1.0, node-addon-api@^4.0.0, node-addon-api@^4.3.0, node-addon-api@^7.1.0, node-addon-api@^8.3.0: version "8.3.0" @@ -4054,11 +4054,6 @@ semver@^7.3.5, semver@^7.5.3: resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== -semver@^7.6.3: - version "7.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" - integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== - serialize-error@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/serialize-error/-/serialize-error-5.0.0.tgz#a7ebbcdb03a5d71a6ed8461ffe0fc1a1afed62ac" diff --git a/scripts/build-macos.mjs b/scripts/build-macos.mjs index 2cb3e0ad4e..ae509ea4c6 100755 --- a/scripts/build-macos.mjs +++ b/scripts/build-macos.mjs @@ -2,6 +2,8 @@ /* eslint-disable @typescript-eslint/prefer-nullish-coalescing */ import { build as builder } from 'electron-builder' import * as vars from './vars.mjs' +import { execFileSync } from 'child_process' +import * as path from 'path' const isTag = (process.env.GITHUB_REF || '').startsWith('refs/tags/') @@ -16,31 +18,89 @@ if (process.env.GITHUB_HEAD_REF) { process.env.APPLE_ID ??= process.env.APPSTORE_USERNAME process.env.APPLE_APP_SPECIFIC_PASSWORD ??= process.env.APPSTORE_PASSWORD -builder({ - dir: true, - mac: ['dmg', 'zip'], - x64: process.env.ARCH === 'x86_64', - arm64: process.env.ARCH === 'arm64', - config: { - extraMetadata: { - version: vars.version, - teamId: process.env.APPLE_TEAM_ID, - }, - mac: { - identity: !process.env.CI || process.env.CSC_LINK ? undefined : null, - notarize: !!process.env.APPLE_TEAM_ID, +const wantsRealSigning = !!(process.env.CSC_LINK || process.env.CSC_NAME || process.env.APPLE_TEAM_ID) + +async function main () { + if (wantsRealSigning) { + await builder({ + dir: true, + mac: ['dmg', 'zip'], + x64: process.env.ARCH === 'x86_64', + arm64: process.env.ARCH === 'arm64', + config: { + extraMetadata: { + version: vars.version, + teamId: process.env.APPLE_TEAM_ID, + }, + mac: { + notarize: false, + }, + npmRebuild: process.env.ARCH !== 'arm64', + publish: process.env.KEYGEN_TOKEN ? [ + vars.keygenConfig, + { + provider: 'github', + channel: `latest-${process.env.ARCH}`, + }, + ] : undefined, + }, + publish: (process.env.KEYGEN_TOKEN && isTag) ? 'always' : 'never', + }) + return + } + + await builder({ + dir: true, + mac: ['dir'], + x64: process.env.ARCH === 'x86_64', + arm64: process.env.ARCH === 'arm64', + config: { + extraMetadata: { + version: vars.version, + }, + mac: { + identity: null, + hardenedRuntime: false, + entitlements: null, + entitlementsInherit: null, + notarize: false, + }, + npmRebuild: process.env.ARCH !== 'arm64', + publish: undefined, }, - npmRebuild: process.env.ARCH !== 'arm64', - publish: process.env.KEYGEN_TOKEN ? [ - vars.keygenConfig, - { - provider: 'github', - channel: `latest-${process.env.ARCH}`, + publish: 'never', + }) + + const appDir = path.join(process.cwd(), 'dist', process.env.ARCH === 'x86_64' ? 'mac' : `mac-${process.env.ARCH}`) + const appPath = path.join(appDir, 'Tabby.app') + + execFileSync('/usr/bin/codesign', ['--force', '--deep', '--sign', '-', appPath], { stdio: 'inherit' }) + execFileSync('/usr/bin/codesign', ['--verify', '--deep', '--strict', appPath], { stdio: 'inherit' }) + + await builder({ + prepackaged: appPath, + mac: ['dmg', 'zip'], + x64: process.env.ARCH === 'x86_64', + arm64: process.env.ARCH === 'arm64', + config: { + extraMetadata: { + version: vars.version, + }, + mac: { + identity: null, + hardenedRuntime: false, + entitlements: null, + entitlementsInherit: null, + notarize: false, }, - ] : undefined, - }, - publish: (process.env.KEYGEN_TOKEN && isTag) ? 'always' : 'never', -}).catch(e => { + npmRebuild: false, + publish: undefined, + }, + publish: 'never', + }) +} + +main().catch(e => { console.error(e) process.exit(1) }) diff --git a/tabby-core/src/components/appRoot.component.pug b/tabby-core/src/components/appRoot.component.pug index 59ef961fe9..7db98b2df0 100644 --- a/tabby-core/src/components/appRoot.component.pug +++ b/tabby-core/src/components/appRoot.component.pug @@ -13,7 +13,7 @@ title-bar( [class.tabs-on-right]='hasVerticalTabs() && config.store.appearance.tabsLocation == "right"', ) .tab-bar( - *ngIf='!hostWindow.isFullscreen || config.store.appearance.tabsInFullscreen', + *ngIf='true', [class.tab-bar-no-controls-overlay]='hostApp.platform == Platform.macOS', (dblclick)='!isTitleBarNeeded() && toggleMaximize()' ) diff --git a/tabby-core/src/components/appRoot.component.scss b/tabby-core/src/components/appRoot.component.scss index 15cab86862..4f064ff8e5 100644 --- a/tabby-core/src/components/appRoot.component.scss +++ b/tabby-core/src/components/appRoot.component.scss @@ -171,6 +171,13 @@ $tab-border-radius: 4px; } } +:host.fullscreen { + .tab-bar { + z-index: 5000; + background: #222; // ensure background is opaque so content doesn't bleed through + } +} + .content { flex: 1 1 0; position: relative; diff --git a/tabby-core/src/components/appRoot.component.ts b/tabby-core/src/components/appRoot.component.ts index 8f781711c6..c5a83877b7 100644 --- a/tabby-core/src/components/appRoot.component.ts +++ b/tabby-core/src/components/appRoot.component.ts @@ -69,6 +69,7 @@ export class AppRootComponent { @HostBinding('class.platform-win32') platformClassWindows = process.platform === 'win32' @HostBinding('class.platform-darwin') platformClassMacOS = process.platform === 'darwin' @HostBinding('class.platform-linux') platformClassLinux = process.platform === 'linux' + @HostBinding('class.fullscreen') get isFullscreen () { return this.hostWindow.isFullscreen } @HostBinding('class.no-tabs') noTabs = true @ViewChildren(TabBodyComponent) tabBodies: TabBodyComponent[] @ViewChild('activeTransfersDropdown') activeTransfersDropdown: NgbDropdown diff --git a/tabby-core/src/components/splitTabSpanner.component.scss b/tabby-core/src/components/splitTabSpanner.component.scss index 1bc9cfb9ec..ae8bb812e7 100644 --- a/tabby-core/src/components/splitTabSpanner.component.scss +++ b/tabby-core/src/components/splitTabSpanner.component.scss @@ -4,15 +4,36 @@ z-index: 5; transition: 0.125s background; + &::after { + content: ''; + position: absolute; + background: #d3d3d3; + display: block; + } + &.v { cursor: ns-resize; height: 10px; margin-top: -5px; + + &::after { + left: 0; + right: 0; + top: 50%; + height: 1px; + } } &.h { cursor: ew-resize; width: 10px; margin-left: -5px; + + &::after { + top: 0; + bottom: 0; + left: 50%; + width: 1px; + } } } diff --git a/tabby-electron/src/services/hostWindow.service.ts b/tabby-electron/src/services/hostWindow.service.ts index c6372c10a1..37ea150166 100644 --- a/tabby-electron/src/services/hostWindow.service.ts +++ b/tabby-electron/src/services/hostWindow.service.ts @@ -16,6 +16,7 @@ export class ElectronHostWindow extends HostWindowService { private _isFullscreen = false private _isMaximized = false + private repaintScheduled = false constructor ( zone: NgZone, @@ -31,7 +32,10 @@ export class ElectronHostWindow extends HostWindowService { this._isFullscreen = false })) - electron.ipcRenderer.on('host:window-shown', () => zone.run(() => this.windowShown.next())) + electron.ipcRenderer.on('host:window-shown', () => zone.run(() => { + this.windowShown.next() + this.scheduleRepaint() + })) electron.ipcRenderer.on('host:window-close-request', () => zone.run(() => { this.windowCloseRequest.next() @@ -43,6 +47,7 @@ export class ElectronHostWindow extends HostWindowService { electron.ipcRenderer.on('host:window-focused', () => zone.run(() => { this.windowFocused.next() + this.scheduleRepaint() })) electron.ipcRenderer.on('host:became-main-window', () => zone.run(() => { @@ -127,4 +132,22 @@ export class ElectronHostWindow extends HostWindowService { bringToFront (): void { this.electron.ipcRenderer.send('window-bring-to-front') } + + private scheduleRepaint (): void { + if (this.repaintScheduled) { + return + } + this.repaintScheduled = true + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this.repaintScheduled = false + try { + window.dispatchEvent(new Event('resize')) + } catch {} + try { + this.getWindow().webContents.invalidate() + } catch {} + }) + }) + } } diff --git a/tabby-electron/src/sftpContextMenu.ts b/tabby-electron/src/sftpContextMenu.ts index 52888d5037..d2ec7501b2 100644 --- a/tabby-electron/src/sftpContextMenu.ts +++ b/tabby-electron/src/sftpContextMenu.ts @@ -31,7 +31,11 @@ export class EditSFTPContextMenu extends SFTPContextMenuItemProvider { ] if (!item.isDirectory) { items.push({ - click: () => this.edit(item, panel.sftp), + click: () => { + if (panel.sftp) { + this.edit(item, panel.sftp) + } + }, label: this.translate.instant('Edit locally'), }) } diff --git a/tabby-local/src/session.ts b/tabby-local/src/session.ts index 0847139cb1..c6b510c134 100644 --- a/tabby-local/src/session.ts +++ b/tabby-local/src/session.ts @@ -7,15 +7,15 @@ import { SessionOptions, ChildProcess, PTYInterface, PTYProxy } from './api' const windowsDirectoryRegex = /([a-zA-Z]:[^\:\[\]\?\"\<\>\|]+)/mi -function mergeEnv (...envs) { - const result = {} - const keyMap = {} +function mergeEnv (...envs: Array>) : Record { + const result: Record = {} + const keyMap: Record = {} for (const env of envs) { for (const [key, value] of Object.entries(env)) { // const lookup = process.platform === 'win32' ? key.toLowerCase() : key const lookup = key.toLowerCase() keyMap[lookup] ??= key - result[keyMap[lookup]] = value + result[keyMap[lookup]] = value?.toString() ?? '' } } return result @@ -95,6 +95,15 @@ export class Session extends BaseSession { LC_MONETARY: locale, }) } + if (this.hostApp.platform === Platform.macOS) { + if (!env.CLICOLOR || env.CLICOLOR === '0' || env.CLICOLOR.toLowerCase() === 'false') { + env.CLICOLOR = '1' + } + if (!env.CLICOLOR_FORCE || env.CLICOLOR_FORCE === '0' || env.CLICOLOR_FORCE.toLowerCase() === 'false') { + env.CLICOLOR_FORCE = '1' + } + env.LSCOLORS ??= 'exfxcxdxbxegedabagacad' + } // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing let cwd = options.cwd || process.env.HOME diff --git a/tabby-ssh/src/api/fileSystem.ts b/tabby-ssh/src/api/fileSystem.ts new file mode 100644 index 0000000000..382135b33d --- /dev/null +++ b/tabby-ssh/src/api/fileSystem.ts @@ -0,0 +1,33 @@ + +export interface FileEntry { + name: string + fullPath: string + isDirectory: boolean + isSymlink: boolean + mode: number + size: number + modified: Date +} + +export interface FileHandle { + read (): Promise + write (chunk: Uint8Array): Promise + close (): Promise +} + +export abstract class FileSystem { + abstract get pathSeparator (): string + abstract join (...paths: string[]): string + abstract dirname (p: string): string + abstract basename (p: string): string + abstract resolve (p: string): string + + abstract readdir (p: string): Promise + abstract stat (p: string): Promise + abstract open (p: string, mode: number): Promise + abstract mkdir (p: string): Promise + abstract rmdir (p: string): Promise + abstract unlink (p: string): Promise + abstract rename (oldPath: string, newPath: string): Promise + abstract chmod (p: string, mode: string|number): Promise +} diff --git a/tabby-ssh/src/components/fileBrowserPane.component.pug b/tabby-ssh/src/components/fileBrowserPane.component.pug new file mode 100644 index 0000000000..81dac30af1 --- /dev/null +++ b/tabby-ssh/src/components/fileBrowserPane.component.pug @@ -0,0 +1,98 @@ +.header + input.form-control.flex-grow-1.w-0( + *ngIf='editingPath !== null', + type='text', + autofocus, + (keydown.enter)='confirmPath()', + (keydown.esc)='editingPath = null', + (blur)='editingPath = null', + [(ngModel)]='editingPath' + ) + .breadcrumb(*ngIf='editingPath === null', (dblclick)='editPath()') + a.breadcrumb-item.text-decoration-none((click)='navigate("/")') Root + a.breadcrumb-item.text-decoration-none( + *ngFor='let segment of pathSegments', + (click)='navigate(segment.path)' + ) {{segment.name}} + + .breadcrumb-spacer.flex-grow-1.h-100((dblclick)='editPath()') + + button.btn.btn-link.btn-sm.flex-shrink-0.d-flex(*ngIf='!showFilter', (click)='showFilter = true') + i.fas.fa-filter.me-1 + div(translate) Filter + + button.btn.btn-link.btn-sm.flex-shrink-0.d-flex((click)='openCreateDirectoryModal()') + i.fas.fa-plus.me-1 + div(translate) Create directory + + button.btn.btn-link.btn-sm.flex-shrink-0.d-flex((click)='upload()') + i.fas.fa-upload.me-1 + div(translate) Upload files + + button.btn.btn-link.btn-sm.flex-shrink-0.d-flex((click)='uploadFolder()') + i.fas.fa-upload.me-1 + div(translate) Upload folder + +.filter-bar.px-3.py-2.border-bottom(*ngIf='showFilter') + .input-group + input.form-control( + type='text', + placeholder='Filter...', + autofocus, + [(ngModel)]='filterText', + (input)='onFilterChange()', + (keydown.escape)='clearFilter()' + ) + button.btn.btn-secondary((click)='clearFilter()') + i.fas.fa-times + +.body( + dropZone, + (transfer)='uploadOneFolder($event)', + (dragover)='onDragOver($event)', + (dragenter)='onDragEnter($event)', + (dragleave)='onDragLeave($event)', + (drop)='onDrop($event)', + [class.drag-over]='isDragOver' +) + .drag-overlay(*ngIf='isDragOver') + .drag-message + i.fas.fa-cloud-upload-alt.fa-3x.mb-2 + div Drop files here to copy + + a.alert.alert-info.d-flex.align-items-center( + *ngIf='shouldShowCWDTip && !cwdDetectionAvailable', + (click)='platform.openExternal("https://tabby.sh/go/cwd-detection")' + ) + .me-auto + strong(translate) Working directory detection + div(translate) Learn how to allow Tabby to detect remote shell's working directory. + button.close((click)='dismissCWDTip()') + i.fas.fa-close + + div(*ngIf='!fileSystem', translate) Connecting + div(*ngIf='fileSystem') + div(*ngIf='fileList === null', translate) Loading + .list-group.list-group-light(*ngIf='fileList !== null') + .list-group-item.list-group-item-action.d-flex.align-items-center( + *ngIf='path !== "/" && (!showFilter || filterText.trim() === "")', + (click)='goUp()' + ) + i.fas.fa-fw.fa-level-up-alt + div(translate) Go up + .list-group-item.list-group-item-action.d-flex.align-items-center( + *ngFor='let item of filteredFileList', + (contextmenu)='showContextMenu(item, $event)', + (click)='open(item)', + draggable='true', + (dragstart)='onDragStart($event, item)' + ) + i.fa-fw([class]='getIcon(item)') + div {{item.name}} + .me-auto + .size(*ngIf='!item.isDirectory') {{item.size|filesize}} + .date {{item.modified|tabbyDate}} + .mode {{getModeString(item)}} + .alert.alert-info.text-center.mt-3(*ngIf='fileList !== null && filteredFileList.length === 0 && showFilter && filterText.trim() !== ""') + i.fas.fa-search.me-2 + span(translate) No files match the filter "{{filterText}}" diff --git a/tabby-ssh/src/components/fileBrowserPane.component.ts b/tabby-ssh/src/components/fileBrowserPane.component.ts new file mode 100644 index 0000000000..fffbe282e2 --- /dev/null +++ b/tabby-ssh/src/components/fileBrowserPane.component.ts @@ -0,0 +1,590 @@ +import { Component, Input, Output, EventEmitter, Inject, Optional } from '@angular/core' +import { FileUpload, DirectoryUpload, DirectoryDownload, MenuItemOptions, NotificationsService, PlatformService, FileDownload } from 'tabby-core' +import { FileSystem, FileEntry } from '../api/fileSystem' +import { SFTPContextMenuItemProvider } from '../api' +import { NgbModal } from '@ng-bootstrap/ng-bootstrap' +import { SFTPCreateDirectoryModalComponent } from './sftpCreateDirectoryModal.component' +import * as russh from 'russh' + +interface PathSegment { + name: string + path: string +} + +@Component({ + selector: 'file-browser-pane', + templateUrl: './fileBrowserPane.component.pug', + styleUrls: ['./sftpPanel.component.scss'], +}) +export class FileBrowserPaneComponent { + static dragState: { + items: FileEntry[], + sourceFileSystem: FileSystem, + sourcePath: string + } | null = null + + @Input() fileSystem: FileSystem + fileList: FileEntry[]|null = null + filteredFileList: FileEntry[] = [] + @Input() path = '/' + @Output() pathChange = new EventEmitter() + pathSegments: PathSegment[] = [] + @Input() cwdDetectionAvailable = false + @Input() paneType: 'left' | 'right' = 'left' + editingPath: string|null = null + showFilter = false + filterText = '' + isDragOver = false + dragOverCounter = 0 + + constructor ( + private ngbModal: NgbModal, + private notifications: NotificationsService, + public platform: PlatformService, + @Optional() @Inject(SFTPContextMenuItemProvider) protected contextMenuProviders: SFTPContextMenuItemProvider[], + ) { + if (this.contextMenuProviders) { + this.contextMenuProviders.sort((a, b) => a.weight - b.weight) + } + } + + async ngOnInit (): Promise { + try { + await this.navigate(this.path) + } catch (error) { + console.warn('Could not navigate to', this.path, ':', error) + this.notifications.error(error.message) + await this.navigate('/') + } + } + + async navigate (newPath: string, fallbackOnError = true): Promise { + const previousPath = this.path + this.path = newPath + this.pathChange.next(this.path) + + this.clearFilter() + + let p = newPath + this.pathSegments = [] + // Basic breadcrumb generation - needs improvement for non-root paths? + // Assuming path starts with / or drive letter + while (p !== '/' && p !== '.' && p !== this.fileSystem.dirname(p)) { + this.pathSegments.unshift({ + name: this.fileSystem.basename(p), + path: p, + }) + p = this.fileSystem.dirname(p) + } + if (p === '/') { + this.pathSegments.unshift({ name: '', path: '/' }) + } else { + this.pathSegments.unshift({ name: p, path: p }) + } + + this.fileList = null + this.filteredFileList = [] + try { + this.fileList = await this.fileSystem.readdir(this.path) + } catch (error) { + this.notifications.error(error.message) + if (previousPath && fallbackOnError) { + this.navigate(previousPath, false) + } + return + } + + const dirKey = a => a.isDirectory ? 1 : 0 + this.fileList.sort((a, b) => + dirKey(b) - dirKey(a) || + a.name.localeCompare(b.name)) + + this.updateFilteredList() + } + + getFileType (fileExtension: string): string { + const codeExtensions = ['js', 'ts', 'py', 'java', 'cpp', 'h', 'cs', 'html', 'css', 'rb', 'php', 'swift', 'go', 'kt', 'sh', 'json', 'cc', 'c', 'xml'] + const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp'] + const pdfExtensions = ['pdf'] + const archiveExtensions = ['zip', 'rar', 'tar', 'gz'] + const wordExtensions = ['doc', 'docx'] + const videoExtensions = ['mp4', 'avi', 'mkv', 'mov'] + const powerpointExtensions = ['ppt', 'pptx'] + const textExtensions = ['txt', 'log'] + const audioExtensions = ['mp3', 'wav', 'flac'] + const excelExtensions = ['xls', 'xlsx'] + + const lowerCaseExtension = fileExtension.toLowerCase() + + if (codeExtensions.includes(lowerCaseExtension)) { + return 'code' + } else if (imageExtensions.includes(lowerCaseExtension)) { + return 'image' + } else if (pdfExtensions.includes(lowerCaseExtension)) { + return 'pdf' + } else if (archiveExtensions.includes(lowerCaseExtension)) { + return 'archive' + } else if (wordExtensions.includes(lowerCaseExtension)) { + return 'word' + } else if (videoExtensions.includes(lowerCaseExtension)) { + return 'video' + } else if (powerpointExtensions.includes(lowerCaseExtension)) { + return 'powerpoint' + } else if (textExtensions.includes(lowerCaseExtension)) { + return 'text' + } else if (audioExtensions.includes(lowerCaseExtension)) { + return 'audio' + } else if (excelExtensions.includes(lowerCaseExtension)) { + return 'excel' + } else { + return 'unknown' + } + } + + getIcon (item: FileEntry): string { + if (item.isDirectory) { + return 'fas fa-folder text-info' + } + if (item.isSymlink) { + return 'fas fa-link text-warning' + } + const fileMatch = /\.([^.]+)$/.exec(item.name) + const extension = fileMatch ? fileMatch[1] : null + if (extension !== null) { + const fileType = this.getFileType(extension) + + switch (fileType) { + case 'unknown': + return 'fas fa-file' + default: + return `fa-solid fa-file-${fileType} ` + } + } + return 'fas fa-file' + } + + goUp (): void { + this.navigate(this.fileSystem.dirname(this.path)) + } + + async open (item: FileEntry): Promise { + if (item.isDirectory) { + await this.navigate(item.fullPath) + } else { + // TODO: Handle opening files? Or just download? + // Existing logic downloads and opens? + await this.download(item.fullPath, item.mode, item.size) + } + } + + async openCreateDirectoryModal (): Promise { + const modal = this.ngbModal.open(SFTPCreateDirectoryModalComponent) + const directoryName = await modal.result.catch(() => null) + if (directoryName?.trim()) { + this.fileSystem.mkdir(this.fileSystem.join(this.path, directoryName)).then(() => { + this.notifications.notice('The directory was created successfully') + this.navigate(this.fileSystem.join(this.path, directoryName)) + }).catch(() => { + this.notifications.error('The directory could not be created') + }) + } + } + + async upload (): Promise { + const transfers = await this.platform.startUpload({ multiple: true }) + await Promise.all(transfers.map(t => this.uploadOne(t))) + } + + async uploadFolder (): Promise { + const transfer = await this.platform.startUploadDirectory() + await this.uploadOneFolder(transfer) + } + + async uploadOneFolder (transfer: DirectoryUpload, accumPath = ''): Promise { + const savedPath = this.path + for(const t of transfer.getChildrens()) { + if (t instanceof DirectoryUpload) { + try { + await this.fileSystem.mkdir(this.fileSystem.join(this.path, accumPath, t.getName())) + } catch { + // Intentionally ignoring errors from making duplicate dirs. + } + await this.uploadOneFolder(t, this.fileSystem.join(accumPath, t.getName())) + } else { + await this.performUpload(this.fileSystem.join(this.path, accumPath, t.getName()), t) + } + } + if (this.path === savedPath) { + await this.navigate(this.path) + } + } + + async uploadOne (transfer: FileUpload): Promise { + const savedPath = this.path + await this.performUpload(this.fileSystem.join(this.path, transfer.getName()), transfer) + if (this.path === savedPath) { + await this.navigate(this.path) + } + } + + async download (itemPath: string, mode: number, size: number): Promise { + const transfer = await this.platform.startDownload(this.fileSystem.basename(itemPath), mode, size) + if (!transfer) { + return + } + await this.performDownload(itemPath, transfer) + } + + async downloadFolder (folder: FileEntry): Promise { + try { + const transfer = await this.platform.startDownloadDirectory(folder.name, 0) + if (!transfer) { + return + } + + const sizeCalculationPromise = this.calculateFolderSizeAndUpdate(folder, transfer) + const downloadPromise = this.downloadFolderRecursive(folder, transfer, '') + + try { + await Promise.all([sizeCalculationPromise, downloadPromise]) + transfer.setStatus('') + transfer.setCompleted(true) + } catch (error) { + transfer.cancel() + throw error + } finally { + transfer.close() + } + } catch (error) { + this.notifications.error(`Failed to download folder: ${error.message}`) + throw error + } + } + + private async calculateFolderSizeAndUpdate (folder: FileEntry, transfer: DirectoryDownload) { + let totalSize = 0 + const items = await this.fileSystem.readdir(folder.fullPath) + for (const item of items) { + if (item.isDirectory) { + totalSize += await this.calculateFolderSizeAndUpdate(item, transfer) + } else { + totalSize += item.size + } + transfer.setTotalSize(totalSize) + } + return totalSize + } + + private async downloadFolderRecursive (folder: FileEntry, transfer: DirectoryDownload, relativePath: string): Promise { + const items = await this.fileSystem.readdir(folder.fullPath) + + for (const item of items) { + if (transfer.isCancelled()) { + throw new Error('Download cancelled') + } + + const itemRelativePath = relativePath ? `${relativePath}/${item.name}` : item.name + + transfer.setStatus(itemRelativePath) + if (item.isDirectory) { + await transfer.createDirectory(itemRelativePath) + await this.downloadFolderRecursive(item, transfer, itemRelativePath) + } else { + const fileDownload = await transfer.createFile(itemRelativePath, item.mode, item.size) + await this.performDownload(item.fullPath, fileDownload) + } + } + } + + // Generic transfer implementation + async performUpload (path: string, transfer: FileUpload): Promise { + const tempPath = path + '.tabby-upload' + try { + const handle = await this.fileSystem.open(tempPath, russh.OPEN_WRITE | russh.OPEN_CREATE) + while (true) { + const chunk = await transfer.read() + if (!chunk.length) { + break + } + await handle.write(chunk) + } + await handle.close() + await this.fileSystem.unlink(path).catch(() => null) + await this.fileSystem.rename(tempPath, path) + transfer.close() + } catch (e) { + transfer.cancel() + this.fileSystem.unlink(tempPath).catch(() => null) + throw e + } + } + + async performDownload (path: string, transfer: FileDownload): Promise { + try { + const handle = await this.fileSystem.open(path, russh.OPEN_READ) + while (true) { + const chunk = await handle.read() + if (!chunk.length) { + break + } + await transfer.write(chunk) + } + transfer.close() + handle.close() + } catch (e) { + transfer.cancel() + throw e + } + } + + getModeString (item: FileEntry): string { + const s = 'SGdrwxrwxrwx' + const e = ' ---------' + const c = [ + 0o4000, 0o2000, 0o40000, + 0o400, 0o200, 0o100, + 0o40, 0o20, 0o10, + 0o4, 0o2, 0o1, + ] + let result = '' + for (let i = 0; i < 12; i++) { + result += (item.mode & c[i]) ? s[i] : e[i] + } + return result + } + + async buildContextMenu (item: FileEntry): Promise { + if (!this.contextMenuProviders) return [] + let items: MenuItemOptions[] = [] + for (const section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(item, this as any)))) { // Cast this to SFTPPanelComponent compatible type? + items.push({ type: 'separator' }) + items = items.concat(section) + } + return items.slice(1) + } + + async showContextMenu (item: FileEntry, event: MouseEvent): Promise { + event.preventDefault() + this.platform.popupContextMenu(await this.buildContextMenu(item), event) + } + + get shouldShowCWDTip (): boolean { + return !window.localStorage.sshCWDTipDismissed + } + + dismissCWDTip (): void { + window.localStorage.sshCWDTipDismissed = 'true' + } + + editPath (): void { + this.editingPath = this.path + } + + confirmPath (): void { + if (this.editingPath === null) { + return + } + this.navigate(this.editingPath) + this.editingPath = null + } + + clearFilter (): void { + this.showFilter = false + this.filterText = '' + this.updateFilteredList() + } + + onFilterChange (): void { + this.updateFilteredList() + } + + onDragStart (event: DragEvent, item: FileEntry): void { + if (event.dataTransfer) { + event.dataTransfer.setData('text/plain', item.name) + event.dataTransfer.effectAllowed = 'copy' + // 设置拖拽时的自定义图像 + event.dataTransfer.setDragImage(event.target as HTMLElement, 0, 0) + } + FileBrowserPaneComponent.dragState = { + items: [item], + sourceFileSystem: this.fileSystem, + sourcePath: this.path + } + } + + onDragOver (event: DragEvent): void { + if (FileBrowserPaneComponent.dragState && event.dataTransfer) { + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } + } + + onDragEnter (event: DragEvent): void { + if (FileBrowserPaneComponent.dragState) { + this.dragOverCounter++ + this.isDragOver = true + } + } + + onDragLeave (event: DragEvent): void { + if (FileBrowserPaneComponent.dragState) { + this.dragOverCounter-- + if (this.dragOverCounter <= 0) { + this.isDragOver = false + this.dragOverCounter = 0 + } + } + } + + async onDrop (event: DragEvent): Promise { + const state = FileBrowserPaneComponent.dragState + if (!state) return + event.preventDefault() + event.stopPropagation() + FileBrowserPaneComponent.dragState = null + this.isDragOver = false + this.dragOverCounter = 0 + + // 处理外部文件拖入(从操作系统拖入) + if (event.dataTransfer?.files && event.dataTransfer.files.length > 0) { + await this.handleExternalFilesDrop(event.dataTransfer.files) + return + } + + // 处理内部拖拽(从一个面板拖到另一个面板) + await this.handleInternalDrop(state) + } + + async handleExternalFilesDrop (files: FileList): Promise { + // 从操作系统拖入文件 + for (let i = 0; i < files.length; i++) { + const file = files[i] + const targetPath = this.fileSystem.join(this.path, file.name) + + // 检查文件是否已存在 + const shouldProceed = await this.checkFileConflict(file.name, targetPath) + if (!shouldProceed) continue + + try { + // 读取文件内容并上传 + const arrayBuffer = await file.arrayBuffer() + const uint8Array = new Uint8Array(arrayBuffer) + await this.writeFileContent(targetPath, uint8Array) + this.notifications.notice(`Uploaded ${file.name}`) + } catch (e) { + this.notifications.error(`Failed to upload ${file.name}: ${e.message}`) + } + } + this.navigate(this.path) + } + + async handleInternalDrop (state: typeof FileBrowserPaneComponent.dragState): Promise { + if (!state) return + + for (const item of state.items) { + const sourceFull = item.fullPath + const targetFull = this.fileSystem.join(this.path, item.name) + + // 如果是同一个文件系统且路径相同,跳过 + if (state.sourceFileSystem === this.fileSystem && sourceFull === targetFull) { + continue + } + + // 检查文件冲突 + const shouldProceed = await this.checkFileConflict(item.name, targetFull) + if (!shouldProceed) continue + + try { + if (item.isDirectory) { + await this.copyDirectory(state.sourceFileSystem, sourceFull, this.fileSystem, targetFull) + } else { + await this.copyFile(state.sourceFileSystem, sourceFull, this.fileSystem, targetFull) + } + this.notifications.notice(`Copied ${item.name}`) + } catch (e) { + this.notifications.error(`Could not copy ${item.name}: ${e.message}`) + } + } + + this.navigate(this.path) + } + + async checkFileConflict (fileName: string, targetPath: string): Promise { + try { + await this.fileSystem.stat(targetPath) + // 文件已存在,显示冲突提示 + const result = await this.platform.showMessageBox({ + type: 'warning', + message: `File "${fileName}" already exists in the destination.`, + detail: `Do you want to overwrite it?`, + buttons: ['Overwrite', 'Skip', 'Cancel'], + defaultId: 0, + cancelId: 2, + }) + if (result.response === 1) return false // Skip + if (result.response === 2) return false // Cancel + return true // Overwrite + } catch { + // 文件不存在,可以安全复制 + return true + } + } + + async writeFileContent (path: string, content: Uint8Array): Promise { + const tempPath = path + '.tabby-upload' + try { + const handle = await this.fileSystem.open(tempPath, russh.OPEN_WRITE | russh.OPEN_CREATE) + await handle.write(content) + await handle.close() + await this.fileSystem.unlink(path).catch(() => null) + await this.fileSystem.rename(tempPath, path) + } catch (e) { + this.fileSystem.unlink(tempPath).catch(() => null) + throw e + } + } + + async copyFile (sourceFs: FileSystem, sourcePath: string, targetFs: FileSystem, targetPath: string): Promise { + const sourceHandle = await sourceFs.open(sourcePath, russh.OPEN_READ) + const targetHandle = await targetFs.open(targetPath, russh.OPEN_WRITE | russh.OPEN_CREATE | russh.OPEN_TRUNCATE) + + try { + while (true) { + const chunk = await sourceHandle.read() + if (chunk.length === 0) break + await targetHandle.write(chunk) + } + } finally { + await sourceHandle.close() + await targetHandle.close() + } + } + + async copyDirectory (sourceFs: FileSystem, sourcePath: string, targetFs: FileSystem, targetPath: string): Promise { + await targetFs.mkdir(targetPath).catch(() => null) + const entries = await sourceFs.readdir(sourcePath) + for (const entry of entries) { + if (entry.isDirectory) { + await this.copyDirectory(sourceFs, entry.fullPath, targetFs, targetFs.join(targetPath, entry.name)) + } else { + await this.copyFile(sourceFs, entry.fullPath, targetFs, targetFs.join(targetPath, entry.name)) + } + } + } + + private updateFilteredList (): void { + if (!this.fileList) { + this.filteredFileList = [] + return + } + + if (!this.showFilter || this.filterText.trim() === '') { + this.filteredFileList = this.fileList + return + } + + this.filteredFileList = this.fileList.filter(item => + item.name.toLowerCase().includes(this.filterText.toLowerCase()), + ) + } +} diff --git a/tabby-ssh/src/components/localFileManagerTab.component.pug b/tabby-ssh/src/components/localFileManagerTab.component.pug new file mode 100644 index 0000000000..535e01a813 --- /dev/null +++ b/tabby-ssh/src/components/localFileManagerTab.component.pug @@ -0,0 +1,66 @@ +.d-flex.flex-column.h-100 + .toolbar.d-flex.align-items-center.px-3.py-2.border-bottom + .btn-group.me-2 + button.btn.btn-sm.btn-secondary( + (click)='goToPath("left", "/")', + title='Go to root' + ) + i.fas.fa-hdd.me-1 + span Root + button.btn.btn-sm.btn-secondary( + (click)='goToPath("left", leftPane?.fs?.resolve("~"))', + title='Go to home' + ) + i.fas.fa-home.me-1 + span Home + + .btn-group.me-2(*ngIf='rightPane') + button.btn.btn-sm.btn-secondary( + (click)='goToPath("right", "/")', + title='Go to root' + ) + i.fas.fa-hdd.me-1 + button.btn.btn-sm.btn-secondary( + (click)='goToPath("right", rightPane?.fs?.resolve("~"))', + title='Go to home' + ) + i.fas.fa-home.me-1 + + .flex-grow-1 + + button.btn.btn-sm.btn-secondary.me-2( + (click)='swapPanes()', + *ngIf='leftPane && rightPane', + title='Swap panes' + ) + i.fas.fa-exchange-alt.me-1 + span Swap + + button.btn.btn-sm.btn-secondary( + (click)='rightPane ? closeRightPane() : showSplitView()', + title='Toggle split view' + ) + i.fas.fa-columns.me-1 + span {{rightPane ? 'Single view' : 'Split view'}} + + .d-flex.flex-grow-1.h-0 + file-browser-pane.h-100.d-flex.flex-column.flex-grow-1( + *ngIf='leftPane', + [fileSystem]='leftPane.fs', + [(path)]='leftPane.path', + [paneType]='"left"' + ) + + .separator(*ngIf='leftPane && rightPane') + button.btn.btn-sm.btn-link.swap-btn( + (click)='swapPanes()', + title='Swap panes' + ) + i.fas.fa-exchange-alt + + file-browser-pane.h-100.d-flex.flex-column.flex-grow-1( + *ngIf='rightPane', + [fileSystem]='rightPane.fs', + [(path)]='rightPane.path', + [paneType]='"right"' + ) diff --git a/tabby-ssh/src/components/localFileManagerTab.component.ts b/tabby-ssh/src/components/localFileManagerTab.component.ts new file mode 100644 index 0000000000..4f502790e1 --- /dev/null +++ b/tabby-ssh/src/components/localFileManagerTab.component.ts @@ -0,0 +1,70 @@ +import { Component, Injector, Input } from '@angular/core' +import { BaseTabComponent, PlatformService } from 'tabby-core' +import { LocalFileSystem } from '../session/localFileSystem' +import { FileSystem } from '../api/fileSystem' +import * as os from 'os' +import * as path from 'path' +import * as fs from 'fs' + +@Component({ + selector: 'local-file-manager-tab', + templateUrl: './localFileManagerTab.component.pug', + styleUrls: ['./sftpPanel.component.scss'], +}) +export class LocalFileManagerTabComponent extends BaseTabComponent { + @Input() initialPath?: string + + leftPane: { fs: FileSystem, path: string } | null = null + rightPane: { fs: FileSystem, path: string } | null = null + private defaultPath: string = os.homedir() + + constructor ( + injector: Injector, + public platform: PlatformService, + ) { + super(injector) + this.setTitle('Local Files') + this.initPanes() + } + + private initPanes (): void { + const localFs = new LocalFileSystem() + + const preferredPath = this.initialPath && fs.existsSync(this.initialPath) ? this.initialPath : null + const homePath = os.homedir() + const desktopPath = path.join(homePath, 'Desktop') + this.defaultPath = preferredPath ?? (fs.existsSync(desktopPath) ? desktopPath : homePath) + + this.leftPane = { + fs: localFs, + path: this.defaultPath + } + + this.rightPane = null + } + + swapPanes (): void { + const temp = this.leftPane + this.leftPane = this.rightPane + this.rightPane = temp + } + + closeRightPane (): void { + this.rightPane = null + } + + showSplitView (): void { + const localFs = new LocalFileSystem() + this.rightPane = { + fs: localFs, + path: this.defaultPath + } + } + + async goToPath (pane: 'left' | 'right', targetPath: string): Promise { + const paneObj = pane === 'left' ? this.leftPane : this.rightPane + if (paneObj) { + paneObj.path = targetPath + } + } +} diff --git a/tabby-ssh/src/components/sftpPanel.component.pug b/tabby-ssh/src/components/sftpPanel.component.pug index 7943f357b2..3ca190c863 100644 --- a/tabby-ssh/src/components/sftpPanel.component.pug +++ b/tabby-ssh/src/components/sftpPanel.component.pug @@ -1,85 +1,34 @@ -.header - input.form-control.flex-grow-1.w-0( - *ngIf='editingPath !== null', - type='text', - autofocus, - (keydown.enter)='confirmPath()', - (keydown.esc)='editingPath = null', - (blur)='editingPath = null', - [(ngModel)]='editingPath' - ) - .breadcrumb(*ngIf='editingPath === null', (dblclick)='editPath()') - a.breadcrumb-item.text-decoration-none((click)='navigate("/")') SFTP - a.breadcrumb-item.text-decoration-none( - *ngFor='let segment of pathSegments', - (click)='navigate(segment.path)' - ) {{segment.name}} - - .breadcrumb-spacer.flex-grow-1.h-100((dblclick)='editPath()') - - button.btn.btn-link.btn-sm.flex-shrink-0.d-flex(*ngIf='!showFilter', (click)='showFilter = true') - i.fas.fa-filter.me-1 - div(translate) Filter - - button.btn.btn-link.btn-sm.flex-shrink-0.d-flex((click)='openCreateDirectoryModal()') - i.fas.fa-plus.me-1 - div(translate) Create directory - - button.btn.btn-link.btn-sm.flex-shrink-0.d-flex((click)='upload()') - i.fas.fa-upload.me-1 - div(translate) Upload files - - button.btn.btn-link.btn-sm.flex-shrink-0.d-flex((click)='uploadFolder()') - i.fas.fa-upload.me-1 - div(translate) Upload folder - - button.btn.btn-link.text-decoration-none((click)='close()') !{require('../../../tabby-core/src/icons/times.svg')} - -.filter-bar.px-3.py-2.border-bottom(*ngIf='showFilter') - .input-group - input.form-control( - type='text', - placeholder='Filter...', - autofocus, - [(ngModel)]='filterText', - (input)='onFilterChange()', - (keydown.escape)='clearFilter()' +.d-flex.flex-column.h-100 + .d-flex.flex-grow-1.h-0 + file-browser-pane.h-100.d-flex.flex-column.flex-grow-1( + *ngIf='leftPane', + [fileSystem]='leftPane.fs', + [path]='leftPane.path', + (pathChange)='onPanePathChange("left", $event)', + [cwdDetectionAvailable]='leftPane.fs === sftp ? cwdDetectionAvailable : true', + [paneType]='"left"' ) - button.btn.btn-secondary((click)='clearFilter()') - i.fas.fa-times -.body(dropZone, (transfer)='uploadOneFolder($event)') - a.alert.alert-info.d-flex.align-items-center( - *ngIf='shouldShowCWDTip && !cwdDetectionAvailable', - (click)='platform.openExternal("https://tabby.sh/go/cwd-detection")' - ) - .me-auto - strong(translate) Working directory detection - div(translate) Learn how to allow Tabby to detect remote shell's working directory. - button.close((click)='dismissCWDTip()') - i.fas.fa-close + .separator(*ngIf='leftPane && rightPane') + button.btn.btn-sm.btn-link.swap-btn((click)='swapPanes()', title='Swap panes') + i.fas.fa-exchange-alt + + file-browser-pane.h-100.d-flex.flex-column.flex-grow-1( + *ngIf='rightPane', + [fileSystem]='rightPane.fs', + [path]='rightPane.path', + (pathChange)='onPanePathChange("right", $event)', + [cwdDetectionAvailable]='rightPane.fs === sftp ? cwdDetectionAvailable : true', + [paneType]='"right"' + ) - div(*ngIf='!sftp', translate) Connecting - div(*ngIf='sftp') - div(*ngIf='fileList === null', translate) Loading - .list-group.list-group-light(*ngIf='fileList !== null') - .list-group-item.list-group-item-action.d-flex.align-items-center( - *ngIf='path !== "/" && (!showFilter || filterText.trim() === "")', - (click)='goUp()' - ) - i.fas.fa-fw.fa-level-up-alt - div(translate) Go up - .list-group-item.list-group-item-action.d-flex.align-items-center( - *ngFor='let item of filteredFileList', - (contextmenu)='showContextMenu(item, $event)', - (click)='open(item)' - ) - i.fa-fw([class]='getIcon(item)') - div {{item.name}} - .me-auto - .size(*ngIf='!item.isDirectory') {{item.size|filesize}} - .date {{item.modified|tabbyDate}} - .mode {{getModeString(item)}} - .alert.alert-info.text-center.mt-3(*ngIf='fileList !== null && filteredFileList.length === 0 && showFilter && filterText.trim() !== ""') - i.fas.fa-search.me-2 - span(translate) No files match the filter "{{filterText}}" + .toolbar.d-flex.align-items-center.px-3.py-2.border-top + button.btn.btn-sm.btn-secondary.me-2((click)='swapPanes()', *ngIf='leftPane && rightPane') + i.fas.fa-exchange-alt.me-2 + span(translate) Swap sides + button.btn.btn-sm.btn-secondary((click)='showRemoteSplit()', *ngIf='!rightPane') + i.fas.fa-columns.me-2 + span(translate) Split view + button.btn.btn-sm.btn-secondary((click)='closeLocal()', *ngIf='rightPane') + i.fas.fa-times.me-2 + span(translate) Close split diff --git a/tabby-ssh/src/components/sftpPanel.component.scss b/tabby-ssh/src/components/sftpPanel.component.scss index 997064257b..b5491126ad 100644 --- a/tabby-ssh/src/components/sftpPanel.component.scss +++ b/tabby-ssh/src/components/sftpPanel.component.scss @@ -1,12 +1,14 @@ :host { display: flex; flex-direction: column; + background: var(--bs-body-bg); > .header { padding: 5px 15px 0 20px; display: flex; align-items: center; flex: none; + background: var(--bs-body-bg); } > .filter-bar { @@ -17,6 +19,35 @@ padding: 10px 20px; flex: 1 1 0; overflow-y: auto; + position: relative; + background: var(--bs-body-bg); + + &.drag-over { + background: rgba(var(--bs-primary-rgb), 0.1); + } + + .drag-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(var(--bs-primary-rgb), 0.2); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + border: 3px dashed var(--bs-primary); + border-radius: 8px; + margin: 10px; + + .drag-message { + text-align: center; + color: var(--bs-primary); + font-weight: bold; + font-size: 1.2em; + } + } } .breadcrumb { @@ -34,8 +65,19 @@ } .list-group-item-action { + cursor: pointer; + transition: background-color 0.2s; + &:hover { - background: rgba(white, .05); + background: rgba(white, .1); + } + + &[draggable="true"] { + cursor: grab; + + &:active { + cursor: grabbing; + } } } @@ -59,3 +101,52 @@ .btn-link svg { width: 12px; } + +.panes-container { + height: 100%; + overflow: hidden; + + .separator { + width: 8px; + background: var(--bs-border-color); + cursor: col-resize; + flex: none; + display: flex; + align-items: center; + justify-content: center; + position: relative; + transition: background 0.2s; + + &:hover { + background: var(--bs-primary); + } + + .swap-btn { + position: absolute; + padding: 2px 6px; + font-size: 10px; + background: var(--bs-secondary); + border: none; + color: white; + border-radius: 3px; + opacity: 0; + transition: opacity 0.2s; + + &:hover { + background: var(--bs-primary); + } + } + + &:hover .swap-btn { + opacity: 1; + } + } + + .pane { + overflow: hidden; + } + + .pane-header { + background: var(--theme-bg-more-2); + } +} diff --git a/tabby-ssh/src/components/sftpPanel.component.ts b/tabby-ssh/src/components/sftpPanel.component.ts index 3bd7f892ec..bdd026647d 100644 --- a/tabby-ssh/src/components/sftpPanel.component.ts +++ b/tabby-ssh/src/components/sftpPanel.component.ts @@ -1,392 +1,94 @@ -import * as C from 'constants' -import { posix as path } from 'path' -import { Component, Input, Output, EventEmitter, Inject, Optional } from '@angular/core' -import { FileUpload, DirectoryUpload, DirectoryDownload, MenuItemOptions, NotificationsService, PlatformService } from 'tabby-core' -import { SFTPSession, SFTPFile } from '../session/sftp' +import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core' import { SSHSession } from '../session/ssh' -import { SFTPContextMenuItemProvider } from '../api' -import { NgbModal } from '@ng-bootstrap/ng-bootstrap' -import { SFTPCreateDirectoryModalComponent } from './sftpCreateDirectoryModal.component' - -interface PathSegment { - name: string - path: string -} +import { SFTPSession } from '../session/sftp' +import { LocalFileSystem } from '../session/localFileSystem' +import { FileSystem } from '../api/fileSystem' +import * as os from 'os' @Component({ selector: 'sftp-panel', templateUrl: './sftpPanel.component.pug', styleUrls: ['./sftpPanel.component.scss'], }) -export class SFTPPanelComponent { +export class SFTPPanelComponent implements OnChanges { @Input() session: SSHSession - @Output() closed = new EventEmitter() - sftp: SFTPSession - fileList: SFTPFile[]|null = null - filteredFileList: SFTPFile[] = [] @Input() path = '/' @Output() pathChange = new EventEmitter() - pathSegments: PathSegment[] = [] @Input() cwdDetectionAvailable = false - editingPath: string|null = null - showFilter = false - filterText = '' + @Output() closed = new EventEmitter() - constructor ( - private ngbModal: NgbModal, - private notifications: NotificationsService, - public platform: PlatformService, - @Optional() @Inject(SFTPContextMenuItemProvider) protected contextMenuProviders: SFTPContextMenuItemProvider[], - ) { - this.contextMenuProviders.sort((a, b) => a.weight - b.weight) - } + sftp: SFTPSession | null = null + localFileSystem: LocalFileSystem | null = null + + leftPane: { fs: FileSystem, path: string } | null = null + rightPane: { fs: FileSystem, path: string } | null = null async ngOnInit (): Promise { this.sftp = await this.session.openSFTP() - try { - await this.navigate(this.path) - } catch (error) { - console.warn('Could not navigate to', this.path, ':', error) - this.notifications.error(error.message) - await this.navigate('/') + const initialRemotePath = this.path || '/' + this.leftPane = { + fs: this.sftp, + path: initialRemotePath, } + this.rightPane = null } - async navigate (newPath: string, fallbackOnError = true): Promise { - const previousPath = this.path - this.path = newPath - this.pathChange.next(this.path) - - this.clearFilter() - - let p = newPath - this.pathSegments = [] - while (p !== '/') { - this.pathSegments.unshift({ - name: path.basename(p), - path: p, - }) - p = path.dirname(p) - } - - this.fileList = null - this.filteredFileList = [] - try { - this.fileList = await this.sftp.readdir(this.path) - } catch (error) { - this.notifications.error(error.message) - if (previousPath && fallbackOnError) { - this.navigate(previousPath, false) - } + ngOnChanges (changes: SimpleChanges): void { + if (!changes.path || !this.sftp) { return } - - const dirKey = a => a.isDirectory ? 1 : 0 - this.fileList.sort((a, b) => - dirKey(b) - dirKey(a) || - a.name.localeCompare(b.name)) - - this.updateFilteredList() - } - - getFileType (fileExtension: string): string { - const codeExtensions = ['js', 'ts', 'py', 'java', 'cpp', 'h', 'cs', 'html', 'css', 'rb', 'php', 'swift', 'go', 'kt', 'sh', 'json', 'cc', 'c', 'xml'] - const imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp'] - const pdfExtensions = ['pdf'] - const archiveExtensions = ['zip', 'rar', 'tar', 'gz'] - const wordExtensions = ['doc', 'docx'] - const videoExtensions = ['mp4', 'avi', 'mkv', 'mov'] - const powerpointExtensions = ['ppt', 'pptx'] - const textExtensions = ['txt', 'log'] - const audioExtensions = ['mp3', 'wav', 'flac'] - const excelExtensions = ['xls', 'xlsx'] - - const lowerCaseExtension = fileExtension.toLowerCase() - - if (codeExtensions.includes(lowerCaseExtension)) { - return 'code' - } else if (imageExtensions.includes(lowerCaseExtension)) { - return 'image' - } else if (pdfExtensions.includes(lowerCaseExtension)) { - return 'pdf' - } else if (archiveExtensions.includes(lowerCaseExtension)) { - return 'archive' - } else if (wordExtensions.includes(lowerCaseExtension)) { - return 'word' - } else if (videoExtensions.includes(lowerCaseExtension)) { - return 'video' - } else if (powerpointExtensions.includes(lowerCaseExtension)) { - return 'powerpoint' - } else if (textExtensions.includes(lowerCaseExtension)) { - return 'text' - } else if (audioExtensions.includes(lowerCaseExtension)) { - return 'audio' - } else if (excelExtensions.includes(lowerCaseExtension)) { - return 'excel' - } else { - return 'unknown' + const remotePane = this.getRemotePane() + if (remotePane && changes.path.currentValue && remotePane.path !== changes.path.currentValue) { + remotePane.path = changes.path.currentValue } } - getIcon (item: SFTPFile): string { - if (item.isDirectory) { - return 'fas fa-folder text-info' - } - if (item.isSymlink) { - return 'fas fa-link text-warning' + private getRemotePane (): { fs: FileSystem, path: string } | null { + if (this.leftPane?.fs === this.sftp) { + return this.leftPane } - const fileMatch = /\.([^.]+)$/.exec(item.name) - const extension = fileMatch ? fileMatch[1] : null - if (extension !== null) { - const fileType = this.getFileType(extension) - - switch (fileType) { - case 'unknown': - return 'fas fa-file' - default: - return `fa-solid fa-file-${fileType} ` - } - } - return 'fas fa-file' - } - - goUp (): void { - this.navigate(path.dirname(this.path)) - } - - async open (item: SFTPFile): Promise { - if (item.isDirectory) { - await this.navigate(item.fullPath) - } else if (item.isSymlink) { - const target = path.resolve(this.path, await this.sftp.readlink(item.fullPath)) - const stat = await this.sftp.stat(target) - if (stat.isDirectory) { - await this.navigate(item.fullPath) - } else { - await this.download(item.fullPath, stat.mode, stat.size) - } - } else { - await this.download(item.fullPath, item.mode, item.size) + if (this.rightPane?.fs === this.sftp) { + return this.rightPane } + return null } - async downloadItem (item: SFTPFile): Promise { - if (item.isDirectory) { - await this.downloadFolder(item) - return - } - - if (item.isSymlink) { - const target = path.resolve(this.path, await this.sftp.readlink(item.fullPath)) - const stat = await this.sftp.stat(target) - if (stat.isDirectory) { - await this.downloadFolder(item) - return - } - await this.download(item.fullPath, stat.mode, stat.size) + onPanePathChange (pane: 'left'|'right', newPath: string): void { + const paneObj = pane === 'left' ? this.leftPane : this.rightPane + if (!paneObj) { return } - - await this.download(item.fullPath, item.mode, item.size) - } - - async openCreateDirectoryModal (): Promise { - const modal = this.ngbModal.open(SFTPCreateDirectoryModalComponent) - const directoryName = await modal.result.catch(() => null) - if (directoryName?.trim()) { - this.sftp.mkdir(path.join(this.path, directoryName)).then(() => { - this.notifications.notice('The directory was created successfully') - this.navigate(path.join(this.path, directoryName)) - }).catch(() => { - this.notifications.error('The directory could not be created') - }) + paneObj.path = newPath + if (this.sftp && paneObj.fs === this.sftp) { + this.path = newPath + this.pathChange.emit(newPath) } } - async upload (): Promise { - const transfers = await this.platform.startUpload({ multiple: true }) - await Promise.all(transfers.map(t => this.uploadOne(t))) - } - - async uploadFolder (): Promise { - const transfer = await this.platform.startUploadDirectory() - await this.uploadOneFolder(transfer) - } - - async uploadOneFolder (transfer: DirectoryUpload, accumPath = ''): Promise { - const savedPath = this.path - for(const t of transfer.getChildrens()) { - if (t instanceof DirectoryUpload) { - try { - await this.sftp.mkdir(path.posix.join(this.path, accumPath, t.getName())) - } catch { - // Intentionally ignoring errors from making duplicate dirs. - } - await this.uploadOneFolder(t, path.posix.join(accumPath, t.getName())) - } else { - await this.sftp.upload(path.posix.join(this.path, accumPath, t.getName()), t) - } - } - if (this.path === savedPath) { - await this.navigate(this.path) + showLocal (): void { + this.localFileSystem = new LocalFileSystem() + this.rightPane = { + fs: this.localFileSystem, + path: os.homedir(), } } - async uploadOne (transfer: FileUpload): Promise { - const savedPath = this.path - await this.sftp.upload(path.join(this.path, transfer.getName()), transfer) - if (this.path === savedPath) { - await this.navigate(this.path) - } + showRemoteSplit (): void { + this.showLocal() } - async download (itemPath: string, mode: number, size: number): Promise { - const transfer = await this.platform.startDownload(path.basename(itemPath), mode, size) - if (!transfer) { - return - } - this.sftp.download(itemPath, transfer) + swapPanes (): void { + const temp = this.leftPane + this.leftPane = this.rightPane + this.rightPane = temp } - async downloadFolder (folder: SFTPFile): Promise { - try { - const transfer = await this.platform.startDownloadDirectory(folder.name, 0) - if (!transfer) { - return - } - - // Start background size calculation and download simultaneously - const sizeCalculationPromise = this.calculateFolderSizeAndUpdate(folder, transfer) - const downloadPromise = this.downloadFolderRecursive(folder, transfer, '') - - try { - await Promise.all([sizeCalculationPromise, downloadPromise]) - transfer.setStatus('') - transfer.setCompleted(true) - } catch (error) { - transfer.cancel() - throw error - } finally { - transfer.close() - } - } catch (error) { - this.notifications.error(`Failed to download folder: ${error.message}`) - throw error - } - } - - private async calculateFolderSizeAndUpdate (folder: SFTPFile, transfer: DirectoryDownload) { - let totalSize = 0 - const items = await this.sftp.readdir(folder.fullPath) - for (const item of items) { - if (item.isDirectory) { - totalSize += await this.calculateFolderSizeAndUpdate(item, transfer) - } else { - totalSize += item.size - } - transfer.setTotalSize(totalSize) - } - return totalSize - } - - private async downloadFolderRecursive (folder: SFTPFile, transfer: DirectoryDownload, relativePath: string): Promise { - const items = await this.sftp.readdir(folder.fullPath) - - for (const item of items) { - if (transfer.isCancelled()) { - throw new Error('Download cancelled') - } - - const itemRelativePath = relativePath ? `${relativePath}/${item.name}` : item.name - - transfer.setStatus(itemRelativePath) - if (item.isDirectory) { - await transfer.createDirectory(itemRelativePath) - await this.downloadFolderRecursive(item, transfer, itemRelativePath) - } else { - const fileDownload = await transfer.createFile(itemRelativePath, item.mode, item.size) - await this.sftp.download(item.fullPath, fileDownload) - } - } - } - - getModeString (item: SFTPFile): string { - const s = 'SGdrwxrwxrwx' - const e = ' ---------' - const c = [ - 0o4000, 0o2000, C.S_IFDIR, - C.S_IRUSR, C.S_IWUSR, C.S_IXUSR, - C.S_IRGRP, C.S_IWGRP, C.S_IXGRP, - C.S_IROTH, C.S_IWOTH, C.S_IXOTH, - ] - let result = '' - for (let i = 0; i < c.length; i++) { - result += item.mode & c[i] ? s[i] : e[i] - } - return result - } - - async buildContextMenu (item: SFTPFile): Promise { - let items: MenuItemOptions[] = [] - for (const section of await Promise.all(this.contextMenuProviders.map(x => x.getItems(item, this)))) { - items.push({ type: 'separator' }) - items = items.concat(section) - } - return items.slice(1) - } - - async showContextMenu (item: SFTPFile, event: MouseEvent): Promise { - event.preventDefault() - this.platform.popupContextMenu(await this.buildContextMenu(item), event) - } - - get shouldShowCWDTip (): boolean { - return !window.localStorage.sshCWDTipDismissed - } - - dismissCWDTip (): void { - window.localStorage.sshCWDTipDismissed = 'true' - } - - editPath (): void { - this.editingPath = this.path - } - - confirmPath (): void { - if (this.editingPath === null) { - return - } - this.navigate(this.editingPath) - this.editingPath = null + closeLocal (): void { + this.rightPane = null + this.localFileSystem = null } close (): void { this.closed.emit() } - - clearFilter (): void { - this.showFilter = false - this.filterText = '' - this.updateFilteredList() - } - - onFilterChange (): void { - this.updateFilteredList() - } - - private updateFilteredList (): void { - if (!this.fileList) { - this.filteredFileList = [] - return - } - - if (!this.showFilter || this.filterText.trim() === '') { - this.filteredFileList = this.fileList - return - } - - this.filteredFileList = this.fileList.filter(item => - item.name.toLowerCase().includes(this.filterText.toLowerCase()), - ) - } } diff --git a/tabby-ssh/src/components/sshTab.component.ts b/tabby-ssh/src/components/sshTab.component.ts index b676dc664f..4e6fccf3bd 100644 --- a/tabby-ssh/src/components/sshTab.component.ts +++ b/tabby-ssh/src/components/sshTab.component.ts @@ -28,6 +28,7 @@ export class SSHTabComponent extends ConnectableTerminalTabComponent session: SSHShellSession|null = null sftpPanelVisible = false sftpPath = '/' + lastRemoteCwd: string|null = null enableToolbar = true activeKIPrompt: KeyboardInteractivePrompt|null = null @@ -157,7 +158,7 @@ export class SSHTabComponent extends ConnectableTerminalTabComponent private async initializeSessionMaybeMultiplex (multiplex = true): Promise { this.sshSession = await this.setupOneSession(this.injector, this.profile, multiplex) - const session = new SSHShellSession(this.injector, this.sshSession, this.profile) + const session = new SSHShellSession(this.injector, this.sshSession, this.profile, this.lastRemoteCwd) this.setSession(session) this.attachSessionHandler(session.serviceMessage$, msg => { @@ -165,6 +166,9 @@ export class SSHTabComponent extends ConnectableTerminalTabComponent this.write(`\r${colors.black.bgWhite(' SSH ')} ${msg}\r\n`) session.resize(this.size.columns, this.size.rows) }) + this.attachSessionHandler(session.oscProcessor.cwdReported$, cwd => { + this.lastRemoteCwd = cwd + }) await session.start() diff --git a/tabby-ssh/src/hotkeys.ts b/tabby-ssh/src/hotkeys.ts index 5017546856..bce6b8c20b 100644 --- a/tabby-ssh/src/hotkeys.ts +++ b/tabby-ssh/src/hotkeys.ts @@ -13,6 +13,10 @@ export class SSHHotkeyProvider extends HotkeyProvider { id: 'launch-winscp', name: this.translate.instant('Launch WinSCP for current SSH session'), }, + { + id: 'open-local-file-manager', + name: this.translate.instant('Open Local File Manager'), + }, ] constructor (private translate: TranslateService) { super() } diff --git a/tabby-ssh/src/index.ts b/tabby-ssh/src/index.ts index 01199e39e0..a82a6948d5 100644 --- a/tabby-ssh/src/index.ts +++ b/tabby-ssh/src/index.ts @@ -14,9 +14,11 @@ import { SSHPortForwardingConfigComponent } from './components/sshPortForwarding import { SSHSettingsTabComponent } from './components/sshSettingsTab.component' import { SSHTabComponent } from './components/sshTab.component' import { SFTPPanelComponent } from './components/sftpPanel.component' +import { FileBrowserPaneComponent } from './components/fileBrowserPane.component' import { SFTPDeleteModalComponent } from './components/sftpDeleteModal.component' import { KeyboardInteractiveAuthComponent } from './components/keyboardInteractiveAuthPanel.component' import { HostKeyPromptModalComponent } from './components/hostKeyPromptModal.component' +import { LocalFileManagerTabComponent } from './components/localFileManagerTab.component' import { SSHConfigProvider } from './config' import { SSHSettingsTabProvider } from './settings' @@ -27,6 +29,10 @@ import { SSHProfilesService } from './profiles' import { SFTPContextMenuItemProvider } from './api/contextMenu' import { CommonSFTPContextMenu } from './sftpContextMenu' import { SFTPCreateDirectoryModalComponent } from './components/sftpCreateDirectoryModal.component' +import { LocalFileManagerTabProvider } from './localFileManagerTabProvider' +import { LocalFileManagerButtonProvider } from './localFileManagerButtonProvider' +import { LocalFileManagerCommandProvider } from './localFileManagerCommandProvider' +import { ToolbarButtonProvider, CommandProvider } from 'tabby-core' /** @hidden */ @NgModule({ @@ -47,6 +53,9 @@ import { SFTPCreateDirectoryModalComponent } from './components/sftpCreateDirect { provide: TabContextMenuItemProvider, useClass: SFTPContextMenu, multi: true }, { provide: ProfileProvider, useExisting: SSHProfilesService, multi: true }, { provide: SFTPContextMenuItemProvider, useClass: CommonSFTPContextMenu, multi: true }, + { provide: TabContextMenuItemProvider, useClass: LocalFileManagerTabProvider, multi: true }, + { provide: ToolbarButtonProvider, useClass: LocalFileManagerButtonProvider, multi: true }, + { provide: CommandProvider, useClass: LocalFileManagerCommandProvider, multi: true }, ], declarations: [ SSHProfileSettingsComponent, @@ -57,8 +66,10 @@ import { SFTPCreateDirectoryModalComponent } from './components/sftpCreateDirect SSHSettingsTabComponent, SSHTabComponent, SFTPPanelComponent, + FileBrowserPaneComponent, KeyboardInteractiveAuthComponent, HostKeyPromptModalComponent, + LocalFileManagerTabComponent, ], }) // eslint-disable-next-line @typescript-eslint/no-extraneous-class diff --git a/tabby-ssh/src/localFileManagerButtonProvider.ts b/tabby-ssh/src/localFileManagerButtonProvider.ts new file mode 100644 index 0000000000..0533cd73b5 --- /dev/null +++ b/tabby-ssh/src/localFileManagerButtonProvider.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@angular/core' +import { ToolbarButtonProvider, ToolbarButton, AppService, TabsService, SplitTabComponent, BaseTabComponent } from 'tabby-core' +import { LocalFileManagerTabComponent } from './components/localFileManagerTab.component' +import { SSHTabComponent } from './components/sshTab.component' +import * as fs from 'fs' + +@Injectable() +export class LocalFileManagerButtonProvider extends ToolbarButtonProvider { + private previousTabs = new WeakMap() + + constructor ( + private app: AppService, + private tabs: TabsService, + ) { + super() + } + + provide (): ToolbarButton[] { + return [ + { + icon: ``, + title: 'Open Local File Manager', + weight: 10, + click: () => { + void this.openInPlace(this.app.activeTab) + }, + }, + ] + } + + private async openInPlace (tab: BaseTabComponent|null): Promise { + if (!tab) { + this.app.openNewTabRaw({ type: LocalFileManagerTabComponent }) + return + } + if (tab instanceof SSHTabComponent) { + return + } + if (!(tab instanceof SplitTabComponent)) { + this.app.openNewTabRaw({ type: LocalFileManagerTabComponent }) + return + } + + const relative = tab.getFocusedTab() ?? tab.getAllTabs()[0] ?? null + if (!relative) { + this.app.openNewTabRaw({ type: LocalFileManagerTabComponent }) + return + } + if (relative instanceof SSHTabComponent) { + return + } + + if (relative instanceof LocalFileManagerTabComponent) { + const previous = this.previousTabs.get(tab) + if (previous) { + this.previousTabs.delete(tab) + tab.replaceTab(relative, previous) + } + return + } + + let cwd: string|null = null + try { + cwd = relative && (relative as any).session?.getWorkingDirectory + ? await (relative as any).session.getWorkingDirectory() + : null + } catch { + cwd = null + } + if (cwd && !fs.existsSync(cwd)) { + cwd = null + } + + const fileManager = this.tabs.create({ + type: LocalFileManagerTabComponent, + inputs: { + initialPath: cwd ?? undefined, + }, + }) + this.previousTabs.set(tab, relative) + tab.replaceTab(relative, fileManager) + } +} diff --git a/tabby-ssh/src/localFileManagerCommandProvider.ts b/tabby-ssh/src/localFileManagerCommandProvider.ts new file mode 100644 index 0000000000..dbd03cc3ea --- /dev/null +++ b/tabby-ssh/src/localFileManagerCommandProvider.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@angular/core' +import { CommandProvider, Command, CommandContext, AppService, TabsService, SplitTabComponent, BaseTabComponent } from 'tabby-core' +import { LocalFileManagerTabComponent } from './components/localFileManagerTab.component' +import * as fs from 'fs' + +@Injectable() +export class LocalFileManagerCommandProvider extends CommandProvider { + private previousTabs = new WeakMap() + + constructor ( + private app: AppService, + private tabs: TabsService, + ) { + super() + } + + async provide (context: CommandContext): Promise { + return [ + { + id: 'open-local-file-manager', + label: 'Open Local File Manager', + sublabel: 'Open a graphical file manager for local files', + icon: ``, + run: async () => { + await this.openInPlace(context.tab ?? this.app.activeTab) + }, + }, + ] + } + + private async openInPlace (tab: BaseTabComponent|null): Promise { + if (!tab) { + this.app.openNewTabRaw({ type: LocalFileManagerTabComponent }) + return + } + if (!(tab instanceof SplitTabComponent)) { + this.app.openNewTabRaw({ type: LocalFileManagerTabComponent }) + return + } + + const relative = tab.getFocusedTab() ?? tab.getAllTabs()[0] ?? null + if (!relative) { + this.app.openNewTabRaw({ type: LocalFileManagerTabComponent }) + return + } + + if (relative instanceof LocalFileManagerTabComponent) { + const previous = this.previousTabs.get(tab) + if (previous) { + this.previousTabs.delete(tab) + tab.replaceTab(relative, previous) + } + return + } + + let cwd: string|null = null + try { + cwd = relative && (relative as any).session?.getWorkingDirectory + ? await (relative as any).session.getWorkingDirectory() + : null + } catch { + cwd = null + } + if (cwd && !fs.existsSync(cwd)) { + cwd = null + } + + const fileManager = this.tabs.create({ + type: LocalFileManagerTabComponent, + inputs: { + initialPath: cwd ?? undefined, + }, + }) + this.previousTabs.set(tab, relative) + tab.replaceTab(relative, fileManager) + } +} diff --git a/tabby-ssh/src/localFileManagerTabProvider.ts b/tabby-ssh/src/localFileManagerTabProvider.ts new file mode 100644 index 0000000000..f84be35a8d --- /dev/null +++ b/tabby-ssh/src/localFileManagerTabProvider.ts @@ -0,0 +1,74 @@ +import { Injectable } from '@angular/core' +import { TabContextMenuItemProvider, AppService, MenuItemOptions, TabsService, SplitTabComponent, BaseTabComponent } from 'tabby-core' +import { LocalFileManagerTabComponent } from './components/localFileManagerTab.component' +import * as fs from 'fs' + +@Injectable() +export class LocalFileManagerTabProvider extends TabContextMenuItemProvider { + private previousTabs = new WeakMap() + + constructor ( + private app: AppService, + private tabs: TabsService, + ) { + super() + } + + async getItems (tab: any): Promise { + return [ + { + label: 'Open Local File Manager', + click: () => { + void this.openInPlace(this.app.activeTab) + }, + }, + ] + } + + private async openInPlace (tab: BaseTabComponent|null): Promise { + if (!tab) { + this.app.openNewTabRaw({ type: LocalFileManagerTabComponent }) + return + } + if (!(tab instanceof SplitTabComponent)) { + this.app.openNewTabRaw({ type: LocalFileManagerTabComponent }) + return + } + + const relative = tab.getFocusedTab() ?? tab.getAllTabs()[0] ?? null + if (!relative) { + this.app.openNewTabRaw({ type: LocalFileManagerTabComponent }) + return + } + + if (relative instanceof LocalFileManagerTabComponent) { + const previous = this.previousTabs.get(tab) + if (previous) { + this.previousTabs.delete(tab) + tab.replaceTab(relative, previous) + } + return + } + + let cwd: string|null = null + try { + cwd = relative && (relative as any).session?.getWorkingDirectory + ? await (relative as any).session.getWorkingDirectory() + : null + } catch { + cwd = null + } + if (cwd && !fs.existsSync(cwd)) { + cwd = null + } + + const fileManager = this.tabs.create({ + type: LocalFileManagerTabComponent, + inputs: { + initialPath: cwd ?? undefined, + }, + }) + this.previousTabs.set(tab, relative) + tab.replaceTab(relative, fileManager) + } +} diff --git a/tabby-ssh/src/session/localFileSystem.ts b/tabby-ssh/src/session/localFileSystem.ts new file mode 100644 index 0000000000..c9801dff26 --- /dev/null +++ b/tabby-ssh/src/session/localFileSystem.ts @@ -0,0 +1,116 @@ +import * as fs from 'fs/promises' +import * as path from 'path' +import * as russh from 'russh' +import { FileSystem, FileEntry, FileHandle } from '../api/fileSystem' + +class LocalFileHandle implements FileHandle { + constructor (private handle: fs.FileHandle) { } + + async read (): Promise { + const buffer = Buffer.alloc(256 * 1024) + const result = await this.handle.read(buffer, 0, buffer.length, null) + if (result.bytesRead === 0) { + return new Uint8Array(0) + } + return buffer.subarray(0, result.bytesRead) + } + + async write (chunk: Uint8Array): Promise { + await this.handle.write(chunk) + } + + async close (): Promise { + await this.handle.close() + } +} + +export class LocalFileSystem extends FileSystem { + get pathSeparator (): string { + return path.sep + } + + join (...paths: string[]): string { + return path.join(...paths) + } + + dirname (p: string): string { + return path.dirname(p) + } + + basename (p: string): string { + return path.basename(p) + } + + resolve (p: string): string { + return path.resolve(p) + } + + async readdir (p: string): Promise { + const names = await fs.readdir(p) + const entries: FileEntry[] = [] + for (const name of names) { + try { + const fullPath = path.join(p, name) + const stats = await fs.lstat(fullPath) + entries.push({ + name, + fullPath, + isDirectory: stats.isDirectory(), + isSymlink: stats.isSymbolicLink(), + mode: stats.mode, + size: stats.size, + modified: stats.mtime, + }) + } catch (e) { + console.warn(`Could not stat ${name}`, e) + } + } + return entries + } + + async stat (p: string): Promise { + const stats = await fs.stat(p) + return { + name: path.basename(p), + fullPath: p, + isDirectory: stats.isDirectory(), + isSymlink: stats.isSymbolicLink(), + mode: stats.mode, + size: stats.size, + modified: stats.mtime, + } + } + + async open (p: string, mode: number): Promise { + let flags = 'r' + if ((mode & russh.OPEN_WRITE) && (mode & russh.OPEN_CREATE)) { + flags = 'w+' + } else if (mode & russh.OPEN_WRITE) { + flags = 'w' + } else if (mode & russh.OPEN_APPEND) { + flags = 'a' + } + const handle = await fs.open(p, flags) + return new LocalFileHandle(handle) + } + + async mkdir (p: string): Promise { + await fs.mkdir(p) + } + + async rmdir (p: string): Promise { + await fs.rmdir(p) + } + + async unlink (p: string): Promise { + await fs.unlink(p) + } + + async rename (oldPath: string, newPath: string): Promise { + await fs.rename(oldPath, newPath) + } + + async chmod (p: string, mode: string|number): Promise { + await fs.chmod(p, mode) + } +} diff --git a/tabby-ssh/src/session/sftp.ts b/tabby-ssh/src/session/sftp.ts index 5a3b1e6e15..ac126025b7 100644 --- a/tabby-ssh/src/session/sftp.ts +++ b/tabby-ssh/src/session/sftp.ts @@ -3,19 +3,12 @@ import { Subject, Observable } from 'rxjs' import { posix as posixPath } from 'path' import { Injector } from '@angular/core' import { FileDownload, FileUpload, Logger, LogService } from 'tabby-core' +import { FileSystem, FileEntry, FileHandle } from '../api/fileSystem' import * as russh from 'russh' -export interface SFTPFile { - name: string - fullPath: string - isDirectory: boolean - isSymlink: boolean - mode: number - size: number - modified: Date -} +export type SFTPFile = FileEntry -export class SFTPFileHandle { +export class SFTPFileHandle implements FileHandle { position = 0 constructor ( @@ -42,12 +35,33 @@ export class SFTPFileHandle { } } -export class SFTPSession { +export class SFTPSession extends FileSystem { + get pathSeparator (): string { + return '/' + } + + join (...paths: string[]): string { + return posixPath.join(...paths) + } + + dirname (p: string): string { + return posixPath.dirname(p) + } + + basename (p: string): string { + return posixPath.basename(p) + } + + resolve (p: string): string { + return posixPath.resolve(p) + } + get closed$ (): Observable { return this.closed } private closed = new Subject() private logger: Logger constructor (private sftp: russh.SFTP, injector: Injector) { + super() this.logger = injector.get(LogService).create('sftp') sftp.closed$.subscribe(() => { this.closed.next() diff --git a/tabby-ssh/src/session/shell.ts b/tabby-ssh/src/session/shell.ts index aa98008c1a..98a59e7bb2 100644 --- a/tabby-ssh/src/session/shell.ts +++ b/tabby-ssh/src/session/shell.ts @@ -1,4 +1,4 @@ -import { Observable, Subject } from 'rxjs' +import { Observable, Subject, Subscription } from 'rxjs' import stripAnsi from 'strip-ansi' import { Injector } from '@angular/core' import { LogService } from 'tabby-core' @@ -13,11 +13,17 @@ export class SSHShellSession extends BaseSession { get serviceMessage$ (): Observable { return this.serviceMessage } private serviceMessage = new Subject() private ssh: SSHSession|null + private cwdReportingInstalled = false + private restoredDirectory = false + private restoreAttempts = 0 + private restoreDeadline = 0 + private cwdSubscription?: Subscription constructor ( injector: Injector, ssh: SSHSession, private profile: SSHProfile, + private initialDirectory?: string|null, ) { super(injector.get(LogService).create(`ssh-shell-${profile.options.host}-${profile.options.port}`)) this.ssh = ssh @@ -52,6 +58,12 @@ export class SSHShellSession extends BaseSession { this.logger.debug('Shell open') this.loginScriptProcessor?.executeUnconditionalScripts() + this.installCwdReporting() + this.restoreDeadline = Date.now() + 15000 + this.cwdSubscription = this.oscProcessor.cwdReported$.subscribe(cwd => { + this.maybeRestoreDirectory(cwd) + }) + this.restoreInitialDirectory() this.shell.data$.subscribe(data => { this.emitOutput(Buffer.from(data)) @@ -85,6 +97,87 @@ export class SSHShellSession extends BaseSession { } } + private installCwdReporting (): void { + if (this.cwdReportingInstalled) { + return + } + this.cwdReportingInstalled = true + + const snippet = [ + 'if [ -n "$BASH_VERSION" ]; then', + ' __tabby_osc_cwd(){ printf "\\033]1337;CurrentDir=%s\\007" "$PWD"; }', + ' case ";$PROMPT_COMMAND;" in', + ' *";__tabby_osc_cwd;"*) ;;', + ' *) PROMPT_COMMAND="__tabby_osc_cwd${PROMPT_COMMAND:+;$PROMPT_COMMAND}";;', + ' esac', + ' __tabby_osc_cwd', + 'elif [ -n "$ZSH_VERSION" ]; then', + ' __tabby_osc_cwd(){ print -n "\\033]1337;CurrentDir=$PWD\\007"; }', + ' (( ${precmd_functions[(Ie)__tabby_osc_cwd]} )) || precmd_functions+=(__tabby_osc_cwd)', + ' __tabby_osc_cwd', + 'fi', + ].join('\n') + + this.write(Buffer.from(snippet + '\n')) + } + + + private restoreInitialDirectory (): void { + if (this.restoredDirectory) { + return + } + this.restoredDirectory = true + this.attemptRestoreDirectory() + } + + private maybeRestoreDirectory (currentDir: string): void { + const desired = this.getDesiredDirectory() + if (!desired) { + return + } + if (Date.now() > this.restoreDeadline) { + this.cwdSubscription?.unsubscribe() + return + } + if (this.normalizeDirectory(currentDir) === desired) { + this.cwdSubscription?.unsubscribe() + return + } + this.attemptRestoreDirectory() + } + + private attemptRestoreDirectory (): void { + const desired = this.getDesiredDirectory() + if (!desired || desired === '/') { + return + } + if (this.restoreAttempts >= 3) { + return + } + this.restoreAttempts++ + this.write(Buffer.from(`cd -- ${this.shellEscape(desired)}\n`)) + } + + private getDesiredDirectory (): string|null { + if (!this.initialDirectory) { + return null + } + return this.normalizeDirectory(this.initialDirectory) + } + + private normalizeDirectory (value: string): string { + value = value.trim() + if (value === '/') { + return value + } + value = value.replace(/\/+$/g, '') + return value || '/' + } + + private shellEscape (value: string): string { + return "'" + value.replace(/'/g, "'\\''") + "'" + } + kill (_signal?: string): void { // this.shell?.signal(signal ?? 'TERM') } @@ -92,6 +185,7 @@ export class SSHShellSession extends BaseSession { async destroy (): Promise { this.logger.debug('Closing shell') this.serviceMessage.complete() + this.cwdSubscription?.unsubscribe() this.kill() this.ssh?.unref() this.ssh = null diff --git a/tabby-ssh/src/sftpContextMenu.ts b/tabby-ssh/src/sftpContextMenu.ts index 0a9e05c3b7..b48a19adf7 100644 --- a/tabby-ssh/src/sftpContextMenu.ts +++ b/tabby-ssh/src/sftpContextMenu.ts @@ -22,26 +22,19 @@ export class CommonSFTPContextMenu extends SFTPContextMenuItemProvider { } async getItems (item: SFTPFile, panel: SFTPPanelComponent): Promise { - const items: MenuItemOptions[] = [ - { - click: async () => { - await panel.openCreateDirectoryModal() - }, - label: this.translate.instant('Create directory'), - }, - ] + const items: MenuItemOptions[] = [] // Add download folder option for directories (only in electron) if (item.isDirectory && this.hostApp.platform !== Platform.Web) { items.push({ - click: () => panel.downloadFolder(item), + click: () => this.downloadFolder(item, panel), label: this.translate.instant('Download directory'), }) } if (!item.isDirectory) { items.push({ - click: () => panel.downloadItem(item), + click: () => this.downloadItem(item, panel), label: this.translate.instant('Download'), }) } @@ -58,8 +51,10 @@ export class CommonSFTPContextMenu extends SFTPContextMenuItemProvider { this.translate.instant('Cancel'), ], })).response === 0) { - await this.deleteItem(item, panel.sftp) - panel.navigate(panel.path) + if (panel.sftp) { + await this.deleteItem(item, panel.sftp) + this.refreshPanel(panel) + } } }, label: this.translate.instant('Delete'), @@ -74,4 +69,19 @@ export class CommonSFTPContextMenu extends SFTPContextMenuItemProvider { modal.componentInstance.sftp = session await modal.result.catch(() => null) } + + downloadFolder (item: SFTPFile, panel: SFTPPanelComponent): void { + // TODO: Implement folder download functionality + console.log('Download folder:', item) + } + + downloadItem (item: SFTPFile, panel: SFTPPanelComponent): void { + // TODO: Implement item download functionality + console.log('Download item:', item) + } + + refreshPanel (panel: SFTPPanelComponent): void { + // TODO: Implement panel refresh functionality + console.log('Refresh panel') + } } diff --git a/tabby-terminal/src/api/baseTerminalTab.component.ts b/tabby-terminal/src/api/baseTerminalTab.component.ts index bd814e57a0..a87515feae 100644 --- a/tabby-terminal/src/api/baseTerminalTab.component.ts +++ b/tabby-terminal/src/api/baseTerminalTab.component.ts @@ -447,7 +447,13 @@ export class BaseTerminalTabComponent

extends Bas .subscribe(visibility => { if (this.frontend instanceof XTermFrontend) { if (visibility) { + // Tab 变为可见时,强制重新渲染终端 + this.frontend.xterm.clearTextureAtlas?.() this.frontend.xterm.refresh(0, this.frontend.xterm.rows - 1) + // 触发一个小的 resize 事件来强制重绘 + setTimeout(() => { + ;(this.frontend as XTermFrontend)?.forceResize?.() + }, 50) } else { this.frontend.xterm.element?.querySelectorAll('canvas').forEach(c => { c.height = c.width = 0 diff --git a/tabby-terminal/src/frontends/xtermFrontend.ts b/tabby-terminal/src/frontends/xtermFrontend.ts index a96d468388..728a4e8251 100644 --- a/tabby-terminal/src/frontends/xtermFrontend.ts +++ b/tabby-terminal/src/frontends/xtermFrontend.ts @@ -530,6 +530,21 @@ export class XTermFrontend extends Frontend { return this.xterm.buffer.active.type === 'alternate' } + forceResize (): void { + // 强制重新计算大小并重绘 + try { + if (this.xterm.element && getComputedStyle(this.xterm.element).getPropertyValue('height') !== 'auto') { + this.fitAddon.fit() + // 清除纹理图集以强制重新渲染 + this.webGLAddon?.clearTextureAtlas() + this.canvasAddon?.clearTextureAtlas() + this.xterm.refresh(0, this.xterm.rows - 1) + } + } catch (e) { + console.warn('Could not resize xterm', e) + } + } + private setFontSize () { const scale = Math.pow(1.1, this.zoom) this.xterm.options.fontSize = this.configuredFontSize * scale diff --git a/tabby-terminal/src/middleware/oscProcessing.ts b/tabby-terminal/src/middleware/oscProcessing.ts index 9beff0eefe..d782d6d3e5 100644 --- a/tabby-terminal/src/middleware/oscProcessing.ts +++ b/tabby-terminal/src/middleware/oscProcessing.ts @@ -9,45 +9,60 @@ export class OSCProcessor extends SessionMiddleware { get cwdReported$ (): Observable { return this.cwdReported } private cwdReported = new Subject() + private pendingOSC: Buffer|null = null feedFromSession (data: Buffer): void { - let startIndex = 0 - while (data.includes(OSCPrefix, startIndex)) { - const si = startIndex - if (!OSCSuffixes.some(s => data.includes(s, si))) { + if (this.pendingOSC) { + data = Buffer.concat([this.pendingOSC, data]) + this.pendingOSC = null + } + + let passthrough = data + let searchIndex = 0 + + while (true) { + const prefixIndex = data.indexOf(OSCPrefix, searchIndex) + if (prefixIndex === -1) { break } - const params = data.subarray(data.indexOf(OSCPrefix, startIndex) + OSCPrefix.length) - - const [closesSuffix, closestSuffixIndex] = OSCSuffixes - .map((suffix): [Buffer, number] => [suffix, params.indexOf(suffix)]) + const contentStart = prefixIndex + OSCPrefix.length + const suffixes = OSCSuffixes + .map((suffix): [Buffer, number] => [suffix, data.indexOf(suffix, contentStart)]) .filter(([_, index]) => index !== -1) - .sort(([_, a], [__, b]) => a - b)[0] + .sort(([_, a], [__, b]) => a - b) - const oscString = params.subarray(0, closestSuffixIndex).toString() + if (!suffixes.length) { + this.pendingOSC = data.subarray(prefixIndex) + passthrough = data.subarray(0, prefixIndex) + break + } - startIndex = data.indexOf(closesSuffix, startIndex) + closesSuffix.length + const [closestSuffix, closestSuffixIndex] = suffixes[0] + const oscString = data.subarray(contentStart, closestSuffixIndex).toString() + searchIndex = closestSuffixIndex + closestSuffix.length const [oscCodeString, ...oscParams] = oscString.split(';') const oscCode = parseInt(oscCodeString) + if (oscCode !== 1337) { + continue + } - if (oscCode === 1337) { - const paramString = oscParams.join(';') - if (paramString.startsWith('CurrentDir=')) { - let reportedCWD = paramString.split('=')[1] - if (reportedCWD.startsWith('~')) { - reportedCWD = os.homedir() + reportedCWD.substring(1) - } - this.cwdReported.next(reportedCWD) - } else { - console.debug('Unsupported OSC 1337 parameter:', paramString) - } - } else { + const paramString = oscParams.join(';') + if (!paramString.startsWith('CurrentDir=')) { + console.debug('Unsupported OSC 1337 parameter:', paramString) continue } + + const equalsIndex = paramString.indexOf('=') + let reportedCWD = equalsIndex === -1 ? '' : paramString.substring(equalsIndex + 1) + if (reportedCWD.startsWith('~')) { + reportedCWD = os.homedir() + reportedCWD.substring(1) + } + this.cwdReported.next(reportedCWD) } - super.feedFromSession(data) + + super.feedFromSession(passthrough) } close (): void {