Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Node Explorer: follow symbolic links to directories #249

Merged
merged 4 commits into from
Oct 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 &
marwan-at-work marked this conversation as resolved.
Show resolved Hide resolved
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