From a07d6185757bebb60343417a918eff2d4ef17c76 Mon Sep 17 00:00:00 2001 From: Naman Sood Date: Thu, 5 Oct 2023 11:23:17 -0400 Subject: [PATCH] Node Explorer: follow symbolic links to directories (#249) Fixes #209. --------- Signed-off-by: Naman Sood --- src/filesystem-provider-sftp.ts | 7 +++--- src/filesystem-provider.ts | 4 ++-- src/node-explorer-provider.ts | 30 ++++++++++++++----------- src/sftp.ts | 39 ++++++++++++++++++++++++++++----- 4 files changed, 57 insertions(+), 23 deletions(-) diff --git a/src/filesystem-provider-sftp.ts b/src/filesystem-provider-sftp.ts index c8e789b..a80f2bf 100644 --- a/src/filesystem-provider-sftp.ts +++ b/src/filesystem-provider-sftp.ts @@ -66,8 +66,9 @@ export class FileSystemProviderSFTP implements vscode.FileSystemProvider { const deleteRecursively = async (path: string) => { const st = await sftp.stat(path); - // short circuit for files - if (st.type === vscode.FileType.File) { + // short circuit for files and symlinks + // (don't recursively delete through symlinks that point to directories) + if (st.type & vscode.FileType.File || st.type & vscode.FileType.SymbolicLink) { await sftp.delete(path); return; } @@ -77,7 +78,7 @@ export class FileSystemProviderSFTP implements vscode.FileSystemProvider { for (const [file, fileType] of files) { const filePath = `${path}/${file}`; - if (fileType === vscode.FileType.Directory) { + if (fileType & vscode.FileType.Directory) { await deleteRecursively(filePath); } else { await sftp.delete(filePath); diff --git a/src/filesystem-provider.ts b/src/filesystem-provider.ts index fa2bda1..7cd2a42 100644 --- a/src/filesystem-provider.ts +++ b/src/filesystem-provider.ts @@ -10,10 +10,10 @@ export interface FileSystemProvider extends vscode.FileSystemProvider { // fileSorter mimicks the Node Explorer file structure in that directories // are displayed first in alphabetical followed by files in the same fashion. export function fileSorter(a: [string, vscode.FileType], b: [string, vscode.FileType]): number { - if (a[1] === vscode.FileType.Directory && b[1] !== vscode.FileType.Directory) { + if (a[1] & vscode.FileType.Directory && !(b[1] & vscode.FileType.Directory)) { return -1; } - if (a[1] !== vscode.FileType.Directory && b[1] === vscode.FileType.Directory) { + if (!(a[1] & vscode.FileType.Directory) && b[1] & vscode.FileType.Directory) { return 1; } diff --git a/src/node-explorer-provider.ts b/src/node-explorer-provider.ts index f5cc0b5..58674fe 100644 --- a/src/node-explorer-provider.ts +++ b/src/node-explorer-provider.ts @@ -312,14 +312,14 @@ export class NodeExplorerProvider */ refresh(target?: FileExplorer | FileExplorer[]) { if (Array.isArray(target)) { - if (target.every((item) => item.type === vscode.FileType.Directory)) { + if (target.every((item) => item.type & vscode.FileType.Directory)) { for (const item of target) { this._onDidChangeTreeData.fire([item]); } } else { this._onDidChangeTreeData.fire(undefined); } - } else if (target && target.type === vscode.FileType.Directory) { + } else if (target && target.type & vscode.FileType.Directory) { // If 'target' is a single object this._onDidChangeTreeData.fire([target]); } else { @@ -514,9 +514,13 @@ export class NodeExplorerProvider registerDeleteCommand() { vscode.commands.registerCommand('tailscale.node.fs.delete', async (file: FileExplorer) => { try { - const msg = `Are you sure you want to delete ${ - file.type === vscode.FileType.Directory ? 'this directory' : 'this file' - }? This action cannot be undone.`; + let fileDescription = 'file'; + if (file.type & vscode.FileType.SymbolicLink) { + fileDescription = 'symbolic link'; + } else if (file.type & vscode.FileType.Directory) { + fileDescription = 'directory'; + } + const msg = `Are you sure you want to delete this ${fileDescription}? This action cannot be undone.`; const answer = await vscode.window.showInformationMessage(msg, { modal: true }, 'Yes'); @@ -552,7 +556,7 @@ export class NodeExplorerProvider return; } - if (node.type !== vscode.FileType.Directory) { + if (!(node.type & vscode.FileType.Directory)) { targetPath = path.dirname(resourcePath); } @@ -565,7 +569,7 @@ export class NodeExplorerProvider try { await vscode.workspace.fs.writeFile(newUri, new Uint8Array()); this._onDidChangeTreeData.fire([ - node.type !== vscode.FileType.Directory ? undefined : node, + !(node.type & vscode.FileType.Directory) ? undefined : node, ]); } catch (e) { vscode.window.showErrorMessage(`Could not create directory: ${e}`); @@ -618,7 +622,7 @@ export class NodeExplorerProvider return; } - if (node.type !== vscode.FileType.Directory) { + if (!(node.type & vscode.FileType.Directory)) { const lastSlashIndex = resourcePath.lastIndexOf('/'); targetPath = resourcePath.substring(0, lastSlashIndex); } @@ -632,7 +636,7 @@ export class NodeExplorerProvider try { await vscode.workspace.fs.createDirectory(newUri); this._onDidChangeTreeData.fire([ - node.type !== vscode.FileType.Directory ? undefined : node, + !(node.type & vscode.FileType.Directory) ? undefined : node, ]); } catch (e) { vscode.window.showErrorMessage(`Could not create directory: ${e}`); @@ -777,7 +781,7 @@ export class FileExplorer extends vscode.TreeItem { public readonly uri: vscode.Uri, public readonly type: vscode.FileType, public readonly context?: string, - public readonly collapsibleState: vscode.TreeItemCollapsibleState = type === + public readonly collapsibleState: vscode.TreeItemCollapsibleState = type & vscode.FileType.Directory ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None, @@ -785,7 +789,7 @@ export class FileExplorer extends vscode.TreeItem { ) { super(label, collapsibleState); - if (type === vscode.FileType.File || vscode.FileType.SymbolicLink) { + if (type & vscode.FileType.File) { this.command = { command: 'vscode.open', title: 'Open File', @@ -793,13 +797,13 @@ export class FileExplorer extends vscode.TreeItem { }; } - const typeDesc = type === vscode.FileType.File ? 'file' : 'dir'; + const typeDesc = type & vscode.FileType.File ? 'file' : 'dir'; this.contextValue = `peer-file-explorer-${typeDesc}${context ? `-${context}` : ''}`; } getDirectory(fileName?: string): vscode.Uri { let resourcePath = this.uri.toString(); - if (this.type !== vscode.FileType.Directory) { + if (!(this.type & vscode.FileType.Directory)) { const lastSlashIndex = resourcePath.lastIndexOf('/'); resourcePath = resourcePath.substring(0, lastSlashIndex); } diff --git a/src/sftp.ts b/src/sftp.ts index e99c677..b4f1746 100644 --- a/src/sftp.ts +++ b/src/sftp.ts @@ -1,3 +1,4 @@ +import * as path from 'path'; import * as ssh2 from 'ssh2'; import * as util from 'util'; import * as vscode from 'vscode'; @@ -13,13 +14,29 @@ export class Sftp { return this.sftpPromise; } + async readSymbolicLink(linkPath: string): Promise { + const sftp = await this.getSftp(); + let result = await util.promisify(sftp.readlink).call(sftp, linkPath); + + // if link is relative, not absolute + if (!result.startsWith('/')) { + // note: this needs to be / even on Windows, so don't use path.join() + result = `${path.dirname(linkPath)}/${result}`; + } + + return result; + } + async readDirectory(path: string): Promise<[string, vscode.FileType][]> { const sftp = await this.getSftp(); const files = await util.promisify(sftp.readdir).call(sftp, path); const result: [string, vscode.FileType][] = []; for (const file of files) { - result.push([file.filename, this.convertFileType(file.attrs as ssh2.Stats)]); + result.push([ + file.filename, + await this.convertFileType(file.attrs as ssh2.Stats, `${path}/${file.filename}`), + ]); } return result; @@ -32,10 +49,19 @@ export class Sftp { async stat(path: string): Promise { const sftp = await this.getSftp(); - const s = await util.promisify(sftp.stat).call(sftp, path); + // sftp.lstat, when stat-ing symlinks, will stat the links themselves + // instead of following them. it's necessary to do this and then follow + // the symlinks manually in convertFileType since file.attrs from sftp.readdir + // returns a Stats object that claims to be a symbolic link, but neither a + // file nor a directory. so convertFileType needs to follow symlinks manually + // to figure out whether they point to a file or directory and correctly + // populate the vscode.FileType bitfield. this also allows symlinks to directories + // to not accidentally be treated as directories themselves, so deleting a symlink + // doesn't delete the contents of the directory it points to. + const s = await util.promisify(sftp.lstat).call(sftp, path); return { - type: this.convertFileType(s), + type: await this.convertFileType(s, path), ctime: s.atime, mtime: s.mtime, size: s.size, @@ -83,13 +109,16 @@ export class Sftp { return util.promisify(sftp.fastPut).call(sftp, localPath, remotePath); } - convertFileType(stats: ssh2.Stats): vscode.FileType { + async convertFileType(stats: ssh2.Stats, filename: string): Promise { if (stats.isDirectory()) { return vscode.FileType.Directory; } else if (stats.isFile()) { return vscode.FileType.File; } else if (stats.isSymbolicLink()) { - return vscode.FileType.SymbolicLink; + const sftp = await this.getSftp(); + const target = await this.readSymbolicLink(filename); + const tStat = await util.promisify(sftp.stat).call(sftp, target); + return vscode.FileType.SymbolicLink | (await this.convertFileType(tStat, target)); } else { return vscode.FileType.Unknown; }