Skip to content
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Unreleased

### Added

- Log file picker when viewing logs without an active workspace connection.

## [v1.11.5](https://github.com/coder/vscode-coder/releases/tag/v1.11.5) 2025-12-10

### Added
Expand Down
49 changes: 44 additions & 5 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
type Workspace,
type WorkspaceAgent,
} from "coder/site/src/api/typesGenerated";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as vscode from "vscode";

import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper";
Expand Down Expand Up @@ -225,7 +227,43 @@ export class Commands {
* View the logs for the currently connected workspace.
*/
public async viewLogs(): Promise<void> {
if (!this.workspaceLogPath) {
if (this.workspaceLogPath) {
// Return the connected deployment's log file.
return this.openFile(this.workspaceLogPath);
}

const logDir = vscode.workspace
.getConfiguration()
.get<string>("coder.proxyLogDirectory");
if (logDir) {
try {
const files = await fs.readdir(logDir);
// Sort explicitly since fs.readdir order is platform-dependent
const logFiles = files
.filter((f) => f.endsWith(".log"))
.sort()
.reverse();

if (logFiles.length === 0) {
vscode.window.showInformationMessage(
"No log files found in the configured log directory.",
);
return;
}

const selected = await vscode.window.showQuickPick(logFiles, {
title: "Select a log file to view",
});

if (selected) {
await this.openFile(path.join(logDir, selected));
}
} catch (error) {
vscode.window.showErrorMessage(
`Failed to read log directory: ${error instanceof Error ? error.message : String(error)}`,
);
}
} else {
vscode.window
.showInformationMessage(
"No logs available. Make sure to set coder.proxyLogDirectory to get logs.",
Expand All @@ -239,11 +277,12 @@ export class Commands {
);
}
});
return;
}
const uri = vscode.Uri.file(this.workspaceLogPath);
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
}

private async openFile(filePath: string): Promise<void> {
const uri = vscode.Uri.file(filePath);
await vscode.window.showTextDocument(uri);
}

/**
Expand Down
35 changes: 19 additions & 16 deletions src/remote/sshProcess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ export class SshProcessMonitor implements vscode.Disposable {
while (!this.disposed && this.currentPid === targetPid) {
try {
const logFiles = await fs.readdir(logDir);
logFiles.reverse();
logFiles.sort().reverse();
const logFileName = logFiles.find(
(file) =>
file === `${targetPid}.log` || file.endsWith(`-${targetPid}.log`),
Expand Down Expand Up @@ -404,15 +404,11 @@ async function findRemoteSshLogPath(
// Try extension-specific folder (for VS Code clones like Cursor, Windsurf)
try {
const extensionLogDir = path.join(logsParentDir, extensionId);
// Node returns these directories sorted already!
const files = await fs.readdir(extensionLogDir);
files.reverse();

const remoteSsh = files.find((file) => file.includes("Remote - SSH"));
if (remoteSsh) {
return path.join(extensionLogDir, remoteSsh);
const remoteSshLog = await findSshLogInDir(extensionLogDir);
if (remoteSshLog) {
return remoteSshLog;
}
// Folder exists but no Remote SSH log yet

logger.debug(
`Extension log folder exists but no Remote SSH log found: ${extensionLogDir}`,
);
Expand All @@ -421,18 +417,19 @@ async function findRemoteSshLogPath(
}

try {
// Node returns these directories sorted already!
const dirs = await fs.readdir(logsParentDir);
dirs.reverse();
const outputDirs = dirs.filter((d) => d.startsWith("output_logging_"));
const outputDirs = dirs
.filter((d) => d.startsWith("output_logging_"))
.sort()
.reverse();

if (outputDirs.length > 0) {
const outputPath = path.join(logsParentDir, outputDirs[0]);
const files = await fs.readdir(outputPath);
const remoteSSHLog = files.find((f) => f.includes("Remote - SSH"));
if (remoteSSHLog) {
return path.join(outputPath, remoteSSHLog);
const remoteSshLog = await findSshLogInDir(outputPath);
if (remoteSshLog) {
return remoteSshLog;
}

logger.debug(
`Output logging folder exists but no Remote SSH log found: ${outputPath}`,
);
Expand All @@ -445,3 +442,9 @@ async function findRemoteSshLogPath(

return undefined;
}

async function findSshLogInDir(dirPath: string): Promise<string | undefined> {
const files = await fs.readdir(dirPath);
const remoteSshLog = files.find((f) => f.includes("Remote - SSH"));
return remoteSshLog ? path.join(dirPath, remoteSshLog) : undefined;
}
85 changes: 84 additions & 1 deletion test/unit/remote/sshProcess.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import find from "find-process";
import { vol } from "memfs";
import * as fsPromises from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import {
Expand All @@ -23,7 +24,7 @@ describe("SshProcessMonitor", () => {
let statusBar: MockStatusBar;

beforeEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
vol.reset();
activeMonitors = [];
statusBar = new MockStatusBar();
Expand Down Expand Up @@ -101,6 +102,45 @@ describe("SshProcessMonitor", () => {
expect(pid).toBe(777);
});

it("uses newest output_logging_ directory when multiple exist", async () => {
// Reverse alphabetical order means highest number/newest first
vol.fromJSON({
"/logs/output_logging_20240101/1-Remote - SSH.log":
"-> socksPort 11111 ->",
"/logs/output_logging_20240102/1-Remote - SSH.log":
"-> socksPort 22222 ->",
"/logs/output_logging_20240103/1-Remote - SSH.log":
"-> socksPort 33333 ->",
});

// Mock readdir to return directories in unsorted order (simulating Windows fs)
mockReaddirOrder("/logs", [
"output_logging_20240103",
"output_logging_20240101",
"output_logging_20240102",
"window1",
]);

const monitor = createMonitor({ codeLogDir: "/logs/window1" });
await waitForEvent(monitor.onPidChange);

expect(find).toHaveBeenCalledWith("port", 33333);
});

it("falls back to output_logging_ when extension folder has no SSH log", async () => {
// Extension folder exists but doesn't have Remote SSH log
vol.fromJSON({
"/logs/ms-vscode-remote.remote-ssh/some-other-log.log": "",
"/logs/output_logging_20240101/1-Remote - SSH.log":
"-> socksPort 55555 ->",
});

const monitor = createMonitor({ codeLogDir: "/logs/window1" });
await waitForEvent(monitor.onPidChange);

expect(find).toHaveBeenCalledWith("port", 55555);
});

it("reconnects when network info becomes stale", async () => {
vol.fromJSON({
"/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log":
Expand Down Expand Up @@ -236,6 +276,31 @@ describe("SshProcessMonitor", () => {

expect(monitor.getLogFilePath()).toBeUndefined();
});

it("checks log files in reverse alphabetical order", async () => {
vol.fromJSON({
"/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log":
"-> socksPort 12345 ->",
"/proxy-logs/2024-01-01-999.log": "",
"/proxy-logs/2024-01-02-999.log": "",
"/proxy-logs/2024-01-03-999.log": "",
});

// Mock readdir to return files in unsorted order (simulating Windows fs)
mockReaddirOrder("/proxy-logs", [
"2024-01-03-999.log",
"2024-01-01-999.log",
"2024-01-02-999.log",
]);

const monitor = createMonitor({
codeLogDir: "/logs/window1",
proxyLogDir: "/proxy-logs",
});
const logPath = await waitForEvent(monitor.onLogFilePathChange);

expect(logPath).toBe("/proxy-logs/2024-01-03-999.log");
});
});

describe("network status", () => {
Expand Down Expand Up @@ -407,6 +472,24 @@ describe("SshProcessMonitor", () => {
}
});

/**
* Helper to mock readdir returning files in a specific unsorted order.
* This is needed because memfs returns files in sorted order, which masks
* bugs in sorting logic.
*/
function mockReaddirOrder(dirPath: string, files: string[]): void {
const originalReaddir = fsPromises.readdir;
const mockImpl = (path: fs.PathLike): Promise<string[]> => {
if (path === dirPath) {
return Promise.resolve(files);
}
return originalReaddir(path) as Promise<string[]>;
};
vi.spyOn(fsPromises, "readdir").mockImplementation(
mockImpl as typeof fsPromises.readdir,
);
}

/** Wait for a VS Code event to fire once */
function waitForEvent<T>(
event: (listener: (e: T) => void) => { dispose(): void },
Expand Down