diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d4631710f6d..11bda15ee805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,13 @@ Code v99.99.999 ## Unreleased +### Added + +- New flag `--wait` that waits for the opened files to be closed before + returning, so `code-server --wait` can be used as `EDITOR` or `GIT_EDITOR`. + Before, code-server's own script failed with `Unknown option --wait`, so this + only worked when the integrated terminal found the `remote-cli` script first. + ## [4.145.2](https://github.com/coder/code-server/releases/tag/v4.145.2) - 2026-11-07 Code v1.145.2 diff --git a/src/node/cli.ts b/src/node/cli.ts index 8ed868577356..cd8e96781558 100644 --- a/src/node/cli.ts +++ b/src/node/cli.ts @@ -94,6 +94,7 @@ export interface UserProvidedArgs extends UserProvidedCodeArgs { "skip-auth-preflight"?: boolean "reuse-window"?: boolean "new-window"?: boolean + wait?: boolean "ignore-last-opened"?: boolean verbose?: boolean "app-name"?: string @@ -305,6 +306,10 @@ export const options: Options> = { short: "r", description: "Force to open a file or folder in an already opened window.", }, + wait: { + type: "boolean", + description: "Wait for the files to be closed before returning. Requires an already opened window.", + }, log: { type: LogLevel }, verbose: { type: "boolean", short: "vvv", description: "Enable verbose logging." }, @@ -916,11 +921,11 @@ export const shouldOpenInExistingInstance = async ( // existing instance since these flags have no effect otherwise. That means // if there is no existing instance we should error rather than falling back // to spawning code-server normally. - const openInFlagCount = ["reuse-window", "new-window"].reduce((prev, cur) => { + const openInFlagCount = ["reuse-window", "new-window", "wait"].reduce((prev, cur) => { return args[cur as keyof UserProvidedArgs] ? prev + 1 : prev }, 0) if (openInFlagCount > 0) { - logger.debug("Found --reuse-window or --new-window") + logger.debug("Found --reuse-window, --new-window or --wait") const socketPath = await client.getConnectedSocketPath(paths[0]) if (!socketPath) { throw new Error(`No opened code-server instances found to handle ${paths[0]}`) diff --git a/src/node/main.ts b/src/node/main.ts index 7fcc18b1aad5..d7f26f2fbee8 100644 --- a/src/node/main.ts +++ b/src/node/main.ts @@ -1,16 +1,17 @@ import { field, logger } from "@coder/logger" +import { promises as fs } from "fs" import http from "http" import * as os from "os" import * as path from "path" import { Disposable } from "../common/emitter" -import { plural } from "../common/util" +import { generateUuid, plural } from "../common/util" import { createApp, ensureAddress } from "./app" import { AuthType, DefaultedArgs, Feature, toCodeArgs, UserProvidedArgs } from "./cli" -import { commit, version, vsRootPath } from "./constants" +import { commit, tmpdir, version, vsRootPath } from "./constants" import { loadCustomStrings } from "./i18n" import { register } from "./routes" import { VSCodeModule } from "./routes/vscode" -import { isDirectory, open } from "./util" +import { isDirectory, isFile, isNodeJSErrnoException, open } from "./util" import { wrapper } from "./wrapper" /** @@ -100,23 +101,53 @@ export const openInExistingInstance = async (args: DefaultedArgs, socketPath: st logger.error("Please specify at least one file or folder") process.exit(1) } - const vscode = http.request( - { - path: "/", - method: "POST", - socketPath, - }, - (response) => { - response.on("data", (message) => { - logger.debug("got message from Code", field("message", message.toString())) - }) - }, - ) - vscode.on("error", (error: unknown) => { - logger.error("got error from Code", field("error", error)) - }) - vscode.write(JSON.stringify(pipeArgs)) - vscode.end() + let marker: string | undefined + if (args.wait) { + if (pipeArgs.fileURIs.length === 0) { + logger.error("--wait requires at least one file") + process.exit(1) + } + marker = path.join(tmpdir, generateUuid(16)) + await fs.mkdir(tmpdir, { recursive: true }) + await fs.writeFile(marker, "") + pipeArgs.waitMarkerFilePath = marker + } + try { + await new Promise((resolve, reject) => { + const vscode = http.request( + { + path: "/", + method: "POST", + socketPath, + }, + (response) => { + response.on("data", (message) => { + logger.debug("got message from Code", field("message", message.toString())) + }) + response.on("end", resolve) + }, + ) + vscode.on("error", reject) + vscode.write(JSON.stringify(pipeArgs)) + vscode.end() + }) + } catch (error) { + // Try to clean up the marker, but do not let removal errors override the original error. + if (marker) { + try { + await fs.rm(marker) + } catch (rmError) { + if (!isNodeJSErrnoException(rmError) || rmError.code !== "ENOENT") { + logger.error("failed to clean up wait marker", field("error", rmError)) + } + } + } + throw error + } + // Code deletes the marker file once the opened files are closed. + while (marker && (await isFile(marker))) { + await new Promise((resolve) => setTimeout(resolve, 1000)) + } } export const runCodeServer = async ( diff --git a/test/unit/node/cli.test.ts b/test/unit/node/cli.test.ts index ba5ad6950629..ebd0ab58a61a 100644 --- a/test/unit/node/cli.test.ts +++ b/test/unit/node/cli.test.ts @@ -697,6 +697,14 @@ describe("cli", () => { vscodeSockets.close() }) + it("should not start a new instance if --wait is set", async () => { + const sessionSocket = path.join(tmpDirPath, "session-socket") + const args: UserProvidedArgs = { wait: true, _: ["./file"] } + // There is no instance to open the file in, so this must fail rather than + // falling back to starting code-server. + await expect(shouldOpenInExistingInstance(args, sessionSocket)).rejects.toThrow() + }) + it("should use existing if --new-window is set", async () => { const sessionSocket = path.join(tmpDirPath, "session-socket") const server = await makeEditorSessionManagerServer(sessionSocket, new EditorSessionManager()) diff --git a/test/unit/node/main.test.ts b/test/unit/node/main.test.ts index 39ba2ca8bf9b..b1bb461d6e49 100644 --- a/test/unit/node/main.test.ts +++ b/test/unit/node/main.test.ts @@ -1,7 +1,9 @@ import { promises as fs } from "fs" +import * as http from "http" import * as path from "path" import { setDefaults, parse } from "../../../src/node/cli" import { loadCustomStrings } from "../../../src/node/i18n" +import type { OpenCommandPipeArgs } from "../../../src/node/main" import { tmpdir } from "../../utils/helpers" // Mock the i18n module @@ -173,4 +175,70 @@ describe("main", () => { expect(mockedLoadCustomStrings).toHaveBeenCalledWith(nonExistentFile) }) }) + + describe("openInExistingInstance", () => { + it("should wait for the file to be closed with --wait", async () => { + const file = path.join(tempDir, "git-rebase-todo") + await fs.writeFile(file, "") + + // Stand in for the socket Code listens on for CLI requests. + let pipeArgs: OpenCommandPipeArgs | undefined + let onRequest = () => {} + const requested = new Promise((resolve) => (onRequest = resolve)) + const server = http.createServer((req, res) => { + let body = "" + req.on("data", (chunk) => (body += chunk)) + req.on("end", () => { + pipeArgs = JSON.parse(body) + res.end() + onRequest() + }) + }) + const socketPath = + process.platform === "win32" + ? String.raw`\\.\pipe\vscode-${path.basename(tempDir)}` + : path.join(tempDir, "vscode.sock") + await new Promise((resolve) => server.listen(socketPath, resolve)) + + // Run the wait loop's poll timer as soon as it has been scheduled. + const runNextPoll = async () => { + while (jest.getTimerCount() === 0) { + await fs.stat(tempDir) + } + jest.runOnlyPendingTimers() + } + + jest.useFakeTimers() + try { + const args = await setDefaults( + parse([`--config=${path.join(tempDir, "config.yaml")}`, `--user-data-dir=${tempDir}`, "--wait", file]), + ) + const mainModule = await import("../../../src/node/main") + let returned = false + const opening = mainModule.openInExistingInstance(args, socketPath).then(() => { + returned = true + }) + + await requested + expect(pipeArgs?.fileURIs).toStrictEqual([file]) + const marker = pipeArgs?.waitMarkerFilePath + expect(marker).toBeDefined() + expect((await fs.stat(marker!)).isFile()).toBe(true) + + // Still waiting while the file is open. + await runNextPoll() + await runNextPoll() + expect(returned).toBe(false) + + // Code deletes the marker once the file is closed. + await fs.rm(marker!) + await runNextPoll() + await opening + expect(returned).toBe(true) + } finally { + jest.useRealTimers() + server.close() + } + }) + }) })