Skip to content

Commit

Permalink
Node Explorer: follow symbolic links to directories (#249)
Browse files Browse the repository at this point in the history
Fixes #209.

---------

Signed-off-by: Naman Sood <mail@nsood.in>
  • Loading branch information
tendstofortytwo committed Oct 5, 2023
1 parent 34c6b3f commit a07d618
Show file tree
Hide file tree
Showing 4 changed files with 57 additions and 23 deletions.
7 changes: 4 additions & 3 deletions src/filesystem-provider-sftp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/filesystem-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
30 changes: 17 additions & 13 deletions src/node-explorer-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -552,7 +556,7 @@ export class NodeExplorerProvider
return;
}

if (node.type !== vscode.FileType.Directory) {
if (!(node.type & vscode.FileType.Directory)) {
targetPath = path.dirname(resourcePath);
}

Expand All @@ -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}`);
Expand Down Expand Up @@ -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);
}
Expand All @@ -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}`);
Expand Down Expand Up @@ -777,29 +781,29 @@ 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,
public readonly description: string = ''
) {
super(label, collapsibleState);

if (type === vscode.FileType.File || vscode.FileType.SymbolicLink) {
if (type & vscode.FileType.File) {
this.command = {
command: 'vscode.open',
title: 'Open File',
arguments: [this.uri],
};
}

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);
}
Expand Down
39 changes: 34 additions & 5 deletions src/sftp.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as path from 'path';
import * as ssh2 from 'ssh2';
import * as util from 'util';
import * as vscode from 'vscode';
Expand All @@ -13,13 +14,29 @@ export class Sftp {
return this.sftpPromise;
}

async readSymbolicLink(linkPath: string): Promise<string> {
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;
Expand All @@ -32,10 +49,19 @@ export class Sftp {

async stat(path: string): Promise<vscode.FileStat> {
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,
Expand Down Expand Up @@ -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<vscode.FileType> {
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;
}
Expand Down

0 comments on commit a07d618

Please sign in to comment.