import * as C from 'constants' import { posix as path } from 'path' import { Component, Input, Output, EventEmitter, Inject, Optional } from '@angular/core' import { FileUpload, DirectoryUpload, MenuItemOptions, NotificationsService, PlatformService } from 'tabby-core' import { SFTPSession, SFTPFile } from '../session/sftp' 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 } @Component({ selector: 'sftp-panel', templateUrl: './sftpPanel.component.pug', styleUrls: ['./sftpPanel.component.scss'], }) export class SFTPPanelComponent { @Input() session: SSHSession @Output() closed = new EventEmitter() sftp: SFTPSession fileList: SFTPFile[]|null = null @Input() path = '/' @Output() pathChange = new EventEmitter() pathSegments: PathSegment[] = [] @Input() cwdDetectionAvailable = false editingPath: string|null = null 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) } 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('/') } } async navigate (newPath: string, fallbackOnError = true): Promise { const previousPath = this.path this.path = newPath this.pathChange.next(this.path) let p = newPath this.pathSegments = [] while (p !== '/') { this.pathSegments.unshift({ name: path.basename(p), path: p, }) p = path.dirname(p) } this.fileList = null try { this.fileList = await this.sftp.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)) } 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: SFTPFile): 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(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) } } 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') }) } } 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) } } 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) } } 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) } 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 } close (): void { this.closed.emit() } }