diff --git a/node-explorer-TODO.txt b/node-explorer-TODO.txt
new file mode 100644
index 0000000..534692c
--- /dev/null
+++ b/node-explorer-TODO.txt
@@ -0,0 +1,8 @@
+* Handle offline, or unavailable - show error
+* Add refresh action
+* change user (node context)
+* change root directory (node and FileExplorer context)
+* figure out why the progress bar doesn't show up
+* add file explorer context (get path, delete, rename)
+* download button
+*
\ No newline at end of file
diff --git a/package.json b/package.json
index 17e2017..8229618 100644
--- a/package.json
+++ b/package.json
@@ -125,7 +125,29 @@
{
"command": "tailscale.openAdminConsole",
"group": "overflow",
- "when": "view == tailscale-serve-view"
+ "when": "view == tailscale-serve-view || view == tailscale-node-explorer-view"
+ }
+ ],
+ "view/item/context": [
+ {
+ "command": "tailscale.copyIPv4",
+ "when": "view == tailscale-node-explorer-view && viewItem == tailscale-peer-item",
+ "group": "1_peer@1"
+ },
+ {
+ "command": "tailscale.copyIPv6",
+ "when": "view == tailscale-node-explorer-view && viewItem == tailscale-peer-item",
+ "group": "1_peer@1"
+ },
+ {
+ "command": "tailscale.copyHostname",
+ "when": "view == tailscale-node-explorer-view && viewItem == tailscale-peer-item",
+ "group": "1_peer@1"
+ },
+ {
+ "command": "tailscale.ssh.delete",
+ "group": "fileActions@2",
+ "when": "view == tailscale-node-explorer-view && viewItem == file-explorer-item"
}
]
},
@@ -164,6 +186,22 @@
"command": "tailscale.simpleServeView",
"title": "Simple View",
"category": "tsdev"
+ },
+ {
+ "command": "tailscale.copyIPv4",
+ "title": "Copy IPv4"
+ },
+ {
+ "command": "tailscale.copyIPv6",
+ "title": "Copy IPv6"
+ },
+ {
+ "command": "tailscale.copyHostname",
+ "title": "Copy Hostname"
+ },
+ {
+ "command": "tailscale.ssh.delete",
+ "title": "Delete"
}
],
"viewsContainers": {
@@ -173,6 +211,13 @@
"title": "Tailscale",
"icon": "images/tailscale.svg"
}
+ ],
+ "activitybar": [
+ {
+ "icon": "resources/images/mark.svg",
+ "id": "tailscale-nodes-explorer",
+ "title": "Tailscale"
+ }
]
},
"views": {
@@ -182,6 +227,13 @@
"name": "Funnel",
"type": "webview"
}
+ ],
+ "tailscale-nodes-explorer": [
+ {
+ "id": "tailscale-node-explorer-view",
+ "name": "Nodes",
+ "when": "config.tailscale.nodeExplorer.enabled"
+ }
]
},
"configuration": [
@@ -207,6 +259,11 @@
"examples": [
false
]
+ },
+ "tailscale.nodeExplorer.enabled": {
+ "type": "boolean",
+ "default": false,
+ "markdownDescription": "(IN DEVELOPMENT) Enable the Tailscale Node Explorer view."
}
}
}
diff --git a/resources/dark/offline.svg b/resources/dark/offline.svg
new file mode 100644
index 0000000..270be8d
--- /dev/null
+++ b/resources/dark/offline.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/resources/dark/online.svg b/resources/dark/online.svg
new file mode 100644
index 0000000..a36bcea
--- /dev/null
+++ b/resources/dark/online.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/resources/dark/terminal.svg b/resources/dark/terminal.svg
new file mode 100644
index 0000000..af459c0
--- /dev/null
+++ b/resources/dark/terminal.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/resources/images/mark.svg b/resources/images/mark.svg
new file mode 100644
index 0000000..e71aa90
--- /dev/null
+++ b/resources/images/mark.svg
@@ -0,0 +1,11 @@
+
diff --git a/resources/light/offline.svg b/resources/light/offline.svg
new file mode 100644
index 0000000..270be8d
--- /dev/null
+++ b/resources/light/offline.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/resources/light/online.svg b/resources/light/online.svg
new file mode 100644
index 0000000..a36bcea
--- /dev/null
+++ b/resources/light/online.svg
@@ -0,0 +1,4 @@
+
+
\ No newline at end of file
diff --git a/resources/light/terminal.svg b/resources/light/terminal.svg
new file mode 100644
index 0000000..af459c0
--- /dev/null
+++ b/resources/light/terminal.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/extension.ts b/src/extension.ts
index f51cc3d..53665b9 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -5,6 +5,14 @@ import { ADMIN_CONSOLE } from './utils/url';
import { Tailscale } from './tailscale';
import { Logger } from './logger';
import { errorForType } from './tailscale/error';
+import {
+ FileExplorer,
+ NodeExplorerProvider,
+ PeerDetailTreeItem,
+ PeerTree,
+} from './node-explorer-provider';
+
+import { TSFileSystemProvider } from './ts-file-system-provider';
let tailscaleInstance: Tailscale;
@@ -45,6 +53,22 @@ export async function activate(context: vscode.ExtensionContext) {
tailscaleInstance
);
+ const tsObjFileSystemProvider = new TSFileSystemProvider();
+ context.subscriptions.push(
+ vscode.workspace.registerFileSystemProvider('ts', tsObjFileSystemProvider, {
+ isCaseSensitive: true,
+ })
+ );
+
+ const nodeExplorerProvider = new NodeExplorerProvider(tailscaleInstance);
+ vscode.window.registerTreeDataProvider('tailscale-node-explorer-view', nodeExplorerProvider);
+ const view = vscode.window.createTreeView('tailscale-node-explorer-view', {
+ treeDataProvider: nodeExplorerProvider,
+ showCollapseAll: true,
+ dragAndDropController: nodeExplorerProvider,
+ });
+ context.subscriptions.push(view);
+
context.subscriptions.push(
vscode.commands.registerCommand('tailscale.refreshServe', () => {
Logger.info('called tailscale.refreshServe', 'command');
@@ -74,6 +98,36 @@ export async function activate(context: vscode.ExtensionContext) {
})
);
+ context.subscriptions.push(
+ vscode.commands.registerCommand('tailscale.copyIPv4', async (node: PeerTree) => {
+ const ip = node.TailscaleIPs[0];
+
+ if (!ip) {
+ vscode.window.showErrorMessage(`No IPv4 address found for ${node.HostName}.`);
+ return;
+ }
+
+ await vscode.env.clipboard.writeText(ip);
+ vscode.window.showInformationMessage(`Copied ${ip} to clipboard.`);
+ })
+ );
+
+ context.subscriptions.push(
+ vscode.commands.registerCommand('tailscale.copyIPv6', async (node: PeerTree) => {
+ const ip = node.TailscaleIPs[1];
+ await vscode.env.clipboard.writeText(ip);
+ vscode.window.showInformationMessage(`Copied ${ip} to clipboard.`);
+ })
+ );
+
+ context.subscriptions.push(
+ vscode.commands.registerCommand('tailscale.copyHostname', async (node: PeerTree) => {
+ const name = node.HostName;
+ await vscode.env.clipboard.writeText(name);
+ vscode.window.showInformationMessage(`Copied ${name} to clipboard.`);
+ })
+ );
+
vscode.window.registerWebviewViewProvider('tailscale-serve-view', servePanelProvider);
context.subscriptions.push(
diff --git a/src/node-explorer-provider.ts b/src/node-explorer-provider.ts
new file mode 100644
index 0000000..e72892c
--- /dev/null
+++ b/src/node-explorer-provider.ts
@@ -0,0 +1,257 @@
+/* eslint-disable @typescript-eslint/naming-convention */
+import * as vscode from 'vscode';
+import * as path from 'path';
+import { FileInfo, Peer, Status } from './types';
+import { Tailscale } from './tailscale/cli';
+import { TSFileSystemProvider } from './ts-file-system-provider';
+
+export class NodeExplorerProvider
+ implements
+ vscode.TreeDataProvider,
+ vscode.TreeDragAndDropController,
+ vscode.FileDecorationProvider
+{
+ dropMimeTypes = ['text/uri-list']; // add 'application/vnd.code.tree.testViewDragAndDrop' when we have file explorer
+ dragMimeTypes = [];
+
+ private _onDidChangeTreeData: vscode.EventEmitter<(PeerBaseTreeItem | undefined)[] | undefined> =
+ new vscode.EventEmitter();
+
+ // We want to use an array as the event type, but the API for this is currently being finalized. Until it's finalized, use any.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ public onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event;
+
+ disposable: vscode.Disposable;
+
+ private peers: { [hostName: string]: Peer } = {};
+ private fsProvider: TSFileSystemProvider;
+
+ constructor(private readonly ts: Tailscale) {
+ this.disposable = vscode.window.registerFileDecorationProvider(this);
+ this.fsProvider = new TSFileSystemProvider();
+
+ this.registerDeleteCommand();
+ }
+
+ dispose() {
+ this.disposable.dispose();
+ }
+
+ onDidChangeFileDecorations?: vscode.Event | undefined;
+
+ provideFileDecoration(
+ uri: vscode.Uri,
+ _: vscode.CancellationToken
+ ): vscode.ProviderResult {
+ if (uri.scheme === 'tsobj') {
+ const p = this.peers[uri.authority];
+ if (p?.sshHostKeys?.length) {
+ return {
+ badge: '>_',
+ tooltip: 'You can drag and drop files to this node',
+ };
+ }
+ }
+ return {};
+ }
+
+ getTreeItem(element: PeerBaseTreeItem): vscode.TreeItem {
+ return element;
+ }
+
+ async getChildren(element?: PeerBaseTreeItem): Promise {
+ // File Explorer
+ if (element instanceof FileExplorer) {
+ const dirents = await vscode.workspace.fs.readDirectory(element.uri);
+ return dirents.map(([name, type]) => {
+ const childUri = element.uri.with({ path: `${element.uri.path}/${name}` });
+ return new FileExplorer(name, childUri, type);
+ });
+ }
+
+ // Node root
+ if (element instanceof PeerTree) {
+ return [
+ new FileExplorer(
+ 'File Explorer',
+ // TODO: allow the directory to be configurable
+ vscode.Uri.parse(`ts://nodes/${element.HostName}/~`),
+ vscode.FileType.Directory
+ ),
+ ];
+ } else {
+ // Peer List
+
+ const peers: PeerTree[] = [];
+
+ try {
+ const status = await this.ts.status();
+ if (status.Errors && status.Errors.length) {
+ // TODO: return a proper error
+ return [];
+ }
+ for (const key in status.Peer) {
+ const p = status.Peer[key];
+ this.peers[p.HostName] = p;
+
+ peers.push(new PeerTree({ ...p }));
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } catch (e: any) {
+ vscode.window.showErrorMessage(`unable to fetch status ${e.message}`);
+ console.error(`Error fetching status: ${e}`);
+ }
+
+ return peers;
+ }
+ }
+
+ public async handleDrop(target: FileExplorer, dataTransfer: vscode.DataTransfer): Promise {
+ console.log('handleDrop', target, dataTransfer);
+ // TODO: figure out why the progress bar doesn't show up
+ await vscode.window.withProgress(
+ {
+ location: vscode.ProgressLocation.Window,
+ cancellable: false,
+ title: 'Tailscale',
+ },
+ async (progress) => {
+ dataTransfer.forEach(async ({ value }) => {
+ const uri = vscode.Uri.parse(value);
+ console.log('uri', uri);
+
+ try {
+ await this.fsProvider.scp(uri, target?.uri);
+ console.log('scp done');
+ } catch (e) {
+ vscode.window.showErrorMessage(`unable to copy ${uri} to ${target?.uri}`);
+ console.error(`Error copying ${uri} to ${target?.uri}: ${e}`);
+ }
+
+ progress.report({ increment: 100 });
+ this._onDidChangeTreeData.fire([target]);
+ });
+ }
+ );
+
+ if (!target) {
+ return;
+ }
+ }
+
+ public async handleDrag(
+ source: PeerTree[],
+ treeDataTransfer: vscode.DataTransfer,
+ _: vscode.CancellationToken
+ ): Promise {
+ treeDataTransfer.set(
+ 'application/vnd.code.tree.testViewDragAndDrop',
+ new vscode.DataTransferItem(source)
+ );
+ }
+
+ registerDeleteCommand() {
+ vscode.commands.registerCommand('tailscale.ssh.delete', this.delete.bind(this));
+ }
+
+ async delete(file: FileExplorer) {
+ try {
+ await vscode.workspace.fs.delete(file.uri);
+ vscode.window.showInformationMessage(`${file.label} deleted successfully.`);
+
+ const normalizedPath = path.normalize(file.uri.toString());
+ const parentDir = path.dirname(normalizedPath);
+ const dirName = path.basename(parentDir);
+
+ const parentFileExplorerItem = new FileExplorer(
+ dirName,
+ vscode.Uri.parse(parentDir),
+ vscode.FileType.Directory
+ );
+
+ this._onDidChangeTreeData.fire([parentFileExplorerItem]);
+ console.log('parentFileExplorerItem', parentFileExplorerItem);
+ } catch (e) {
+ vscode.window.showErrorMessage(`Could not delete ${file.label}: ${e}`);
+ }
+ }
+}
+
+export class PeerBaseTreeItem extends vscode.TreeItem {
+ constructor(label: string) {
+ super(vscode.Uri.parse(`tsobj://${label}`));
+ this.label = label;
+ }
+}
+
+export class FileExplorer extends vscode.TreeItem {
+ constructor(
+ public readonly label: string,
+ public readonly uri: vscode.Uri,
+ public readonly type: vscode.FileType,
+ public readonly collapsibleState: vscode.TreeItemCollapsibleState = type ===
+ vscode.FileType.Directory
+ ? vscode.TreeItemCollapsibleState.Collapsed
+ : vscode.TreeItemCollapsibleState.None
+ ) {
+ super(label, collapsibleState);
+
+ if (type === vscode.FileType.File) {
+ this.command = {
+ command: 'vscode.open',
+ title: 'Open File',
+ arguments: [this.uri],
+ };
+ }
+ }
+
+ contextValue = 'file-explorer-item';
+}
+
+export class PeerTree extends PeerBaseTreeItem {
+ public ID: string;
+ public HostName: string;
+ public TailscaleIPs: string[];
+
+ public constructor(obj: Peer) {
+ super(obj.HostName);
+
+ this.ID = obj.ID;
+ this.HostName = obj.HostName;
+ this.TailscaleIPs = obj.TailscaleIPs;
+
+ this.iconPath = {
+ light: path.join(
+ __filename,
+ '..',
+ '..',
+ 'resources',
+ 'light',
+ obj.Online === true ? 'online.svg' : 'offline.svg'
+ ),
+ dark: path.join(
+ __filename,
+ '..',
+ '..',
+ 'resources',
+ 'dark',
+ obj.Online === true ? 'online.svg' : 'offline.svg'
+ ),
+ };
+
+ this.collapsibleState = vscode.TreeItemCollapsibleState.Collapsed;
+ }
+
+ contextValue = 'tailscale-peer-item';
+}
+
+export class PeerDetailTreeItem extends PeerBaseTreeItem {
+ constructor(label: string, description: string, contextValue?: string) {
+ super(label);
+ this.description = description;
+ this.label = label;
+ if (contextValue) {
+ this.contextValue = contextValue;
+ }
+ }
+}
diff --git a/src/tailscale/cli.ts b/src/tailscale/cli.ts
index a4b5cb1..470b252 100644
--- a/src/tailscale/cli.ts
+++ b/src/tailscale/cli.ts
@@ -2,7 +2,7 @@ import * as cp from 'child_process';
import * as vscode from 'vscode';
import fetch from 'node-fetch';
import * as WebSocket from 'ws';
-import type { ServeParams, ServeStatus, TSRelayDetails } from '../types';
+import type { ServeParams, ServeStatus, TSRelayDetails, Status, FileInfo } from '../types';
import { Logger } from '../logger';
import * as path from 'node:path';
import { LogLevel } from 'vscode';
@@ -113,7 +113,7 @@ export class Tailscale {
if (process.env.NODE_ENV === 'development') {
Logger.info(
- `curl "${this.url}/serve" -H "Authorization: Basic ${this.authkey}"`,
+ `curl -H "Authorization: Basic ${this.authkey}" "${this.url}/serve"`,
LOG_COMPONENT
);
}
@@ -238,6 +238,25 @@ export class Tailscale {
}
}
+ async status() {
+ if (!this.url) {
+ throw new Error('uninitialized client');
+ }
+ try {
+ const resp = await fetch(`${this.url}/localapi/v0/status`, {
+ headers: {
+ Authorization: 'Basic ' + this.authkey,
+ },
+ });
+
+ const status = (await resp.json()) as Status;
+ return status;
+ } catch (e) {
+ Logger.error(`error calling status: ${e}`);
+ throw e;
+ }
+ }
+
async serveStatus(): Promise {
if (!this.url) {
throw new Error('uninitialized client');
@@ -252,7 +271,7 @@ export class Tailscale {
const status = (await resp.json()) as ServeStatus;
return status;
} catch (e) {
- Logger.error(`error calling status: ${JSON.stringify(e, null, 2)}`);
+ Logger.error(`error calling serve: ${JSON.stringify(e, null, 2)}`);
throw e;
}
}
diff --git a/src/ts-file-system-provider.ts b/src/ts-file-system-provider.ts
new file mode 100644
index 0000000..7db7f34
--- /dev/null
+++ b/src/ts-file-system-provider.ts
@@ -0,0 +1,272 @@
+import * as vscode from 'vscode';
+import * as path from 'path';
+import { exec } from 'child_process';
+import { Tailscale } from './tailscale/cli';
+import { Logger } from './logger';
+
+export class File implements vscode.FileStat {
+ type: vscode.FileType;
+ ctime: number;
+ mtime: number;
+ size: number;
+
+ name: string;
+ data?: Uint8Array;
+
+ constructor(name: string) {
+ this.type = vscode.FileType.File;
+ this.ctime = Date.now();
+ this.mtime = Date.now();
+ this.size = 0;
+ this.name = name;
+ }
+}
+
+export class Directory implements vscode.FileStat {
+ type: vscode.FileType;
+ ctime: number;
+ mtime: number;
+ size: number;
+
+ name: string;
+ entries: Map;
+
+ constructor(name: string) {
+ this.type = vscode.FileType.Directory;
+ this.ctime = Date.now();
+ this.mtime = Date.now();
+ this.size = 0;
+ this.name = name;
+ this.entries = new Map();
+ }
+}
+
+export type Entry = File | Directory;
+
+export class TSFileSystemProvider implements vscode.FileSystemProvider {
+ // Implementation of the `onDidChangeFile` event
+ onDidChangeFile: vscode.Event = new vscode.EventEmitter<
+ vscode.FileChangeEvent[]
+ >().event;
+
+ watch(): vscode.Disposable {
+ throw new Error('Watch not supported');
+ }
+
+ stat(uri: vscode.Uri): vscode.FileStat | Thenable {
+ Logger.info(`stat: ${uri.toString()}`, 'tsobj-fsp');
+ const { hostname, resourcePath } = this.extractHostAndPath(uri);
+
+ const command = `ssh ${hostname} "stat -L -c '{\\"type\\": \\"%F\\", \\"size\\": %s, \\"ctime\\": %Z, \\"mtime\\": %Y}' ${resourcePath}"`;
+
+ return new Promise((resolve, reject) => {
+ exec(command, (error, stdout) => {
+ if (error) {
+ reject(error);
+ } else {
+ const result = JSON.parse(stdout.trim());
+ const type =
+ result.type === 'directory' ? vscode.FileType.Directory : vscode.FileType.File;
+ const size = result.size || 0;
+ const ctime = result.ctime * 1000;
+ const mtime = result.mtime * 1000;
+ resolve({ type, size, ctime, mtime });
+ }
+ });
+ });
+ }
+
+ readDirectory(uri: vscode.Uri): Promise<[string, vscode.FileType][]> {
+ Logger.info(`readDirectory: ${uri.toString()}`, 'tsobj-fsp');
+
+ const { hostname, resourcePath } = this.extractHostAndPath(uri);
+ Logger.info(`hostname: ${hostname}`, 'tsobj-fsp');
+ Logger.info(`remotePath: ${resourcePath}`, 'tsobj-fsp');
+
+ const command = `ssh ${hostname} ls -Ap "${resourcePath}"`;
+ return new Promise((resolve, reject) => {
+ exec(command, (error, stdout) => {
+ if (error) {
+ reject(error);
+ } else {
+ const lines = stdout.trim().split('\n');
+ const files: [string, vscode.FileType][] = [];
+ for (const line of lines) {
+ const isDirectory = line.endsWith('/');
+ const type = isDirectory ? vscode.FileType.Directory : vscode.FileType.File;
+ const name = isDirectory ? line.slice(0, -1) : line; // Remove trailing slash if it's a directory
+ files.push([name, type]);
+ }
+
+ files.sort((a, b) => {
+ if (a[1] === vscode.FileType.Directory && b[1] !== vscode.FileType.Directory) {
+ return -1;
+ }
+ if (a[1] !== vscode.FileType.Directory && b[1] === vscode.FileType.Directory) {
+ return 1;
+ }
+
+ // If same type, sort by name
+ return a[0].localeCompare(b[0]);
+ });
+
+ resolve(files);
+ }
+ });
+ });
+ }
+
+ readFile(uri: vscode.Uri): Promise {
+ Logger.info(`readFile: ${uri.toString()}`, 'tsobj-readFile');
+ const { hostname, resourcePath } = this.extractHostAndPath(uri);
+ const command = `ssh ${hostname} "cat ${resourcePath}"`;
+ return new Promise((resolve, reject) => {
+ exec(command, (error, stdout) => {
+ if (error) {
+ reject(error);
+ } else {
+ const buffer = Buffer.from(stdout, 'binary');
+ resolve(new Uint8Array(buffer));
+ }
+ });
+ });
+ }
+
+ writeFile(
+ uri: vscode.Uri,
+ content: Uint8Array,
+ options: { create: boolean; overwrite: boolean }
+ ): Promise {
+ Logger.info(`writeFile: ${uri.toString()}`, 'tsobj-fsp');
+
+ const { hostname, resourcePath } = this.extractHostAndPath(uri);
+
+ if (!options.create && !options.overwrite) {
+ throw vscode.FileSystemError.FileExists(uri);
+ }
+
+ const command = `ssh ${hostname} "cat > ${resourcePath}"`;
+ return new Promise((resolve, reject) => {
+ const process = exec(command, (error) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve();
+ }
+ });
+
+ process.stdin?.end(content);
+ });
+ }
+
+ delete(uri: vscode.Uri, options: { recursive: boolean }): Promise {
+ Logger.info(`delete: ${uri.toString()}`, 'tsobj-fsp');
+
+ const { hostname, resourcePath } = this.extractHostAndPath(uri);
+
+ const command = `ssh ${hostname} "rm ${options.recursive ? '-r' : ''} ${resourcePath}"`;
+ return new Promise((resolve, reject) => {
+ exec(command, (error) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve();
+ }
+ });
+ });
+ }
+
+ createDirectory(uri: vscode.Uri): Promise {
+ Logger.info(`createDirectory: ${uri.toString()}`, 'tsobj-fsp');
+
+ const { hostname, resourcePath } = this.extractHostAndPath(uri);
+
+ const command = `ssh ${hostname} mkdir -p ${resourcePath}`;
+ return new Promise((resolve, reject) => {
+ exec(command, (error) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve();
+ }
+ });
+ });
+ }
+
+ rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: { overwrite: boolean }): Promise {
+ Logger.info('rename', 'tsobj-fsp');
+
+ const { hostname: oldHost, resourcePath: oldPath } = this.extractHostAndPath(oldUri);
+ const { hostname: newHost, resourcePath: newPath } = this.extractHostAndPath(newUri);
+
+ if (oldHost !== newHost) {
+ throw new Error('Cannot rename files across different hosts.');
+ }
+
+ const command = `ssh ${oldHost} mv ${options.overwrite ? '-f' : ''} ${oldPath} ${newPath}`;
+ return new Promise((resolve, reject) => {
+ exec(command, (error) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve();
+ }
+ });
+ });
+ }
+
+ // scp pi@haas:/home/pi/foo.txt ubuntu@backup:/home/ubuntu/
+ // scp /Users/Tyler/foo.txt ubuntu@backup:/home/ubuntu/
+ // scp ubuntu@backup:/home/ubuntu/ /Users/Tyler/foo.txt
+
+ scp(src: vscode.Uri, dest: vscode.Uri): Promise {
+ Logger.info('scp', 'tsobj-fsp');
+
+ const { hostname: srcHostName, resourcePath: srcPath } = this.extractHostAndPath(src);
+ const { hostname: destHostName, resourcePath: destPath } = this.extractHostAndPath(dest);
+
+ const command = `scp ${srcPath} ${destHostName}:${destPath}`;
+ console.log('command', command);
+
+ return new Promise((resolve, reject) => {
+ exec(command, (error) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve();
+ }
+ });
+ });
+ }
+
+ private executeShellCommand(command: string): Promise<{ stdout: string; stderr: string }> {
+ return new Promise((resolve, reject) => {
+ exec(command, (error, stdout, stderr) => {
+ if (error) {
+ reject(error);
+ } else {
+ resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
+ }
+ });
+ });
+ }
+
+ private extractHostAndPath(uri: vscode.Uri): { hostname: string | null; resourcePath: string } {
+ switch (uri.scheme) {
+ case 'ts': {
+ // removes leading slash
+ const hostPath = uri.path.slice(1);
+
+ const segments = path.normalize(hostPath).split('/');
+ const [hostname, ...pathSegments] = segments;
+ const resourcePath = decodeURIComponent(pathSegments.join(path.sep));
+
+ return { hostname, resourcePath };
+ }
+ case 'file':
+ return { hostname: null, resourcePath: uri.path };
+ default:
+ throw new Error(`Unsupported scheme: ${uri.scheme}`);
+ }
+ }
+}
diff --git a/src/types.ts b/src/types.ts
index 419e99b..7a4a7c9 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -19,6 +19,21 @@ export interface Handlers {
Proxy: string;
}
+export interface Peer {
+ ID: string;
+ HostName: string;
+ Active?: boolean;
+ Online?: boolean;
+ TailscaleIPs: string[];
+ sshHostKeys?: string[];
+}
+
+export interface Status extends WithErrors {
+ Peer: {
+ [key: string]: Peer;
+ };
+}
+
export interface ServeStatus extends WithErrors {
ServeConfig?: ServeConfig;
FunnelPorts?: number[];
@@ -174,3 +189,9 @@ export interface TSRelayDetails {
nonce: string;
port: string;
}
+
+export interface FileInfo {
+ name: string;
+ isDir: boolean;
+ path: string;
+}