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..296e5fa86901 100644 --- a/src/node/main.ts +++ b/src/node/main.ts @@ -1,4 +1,6 @@ import { field, logger } from "@coder/logger" +import * as crypto from "crypto" +import { promises as fs } from "fs" import http from "http" import * as os from "os" import * as path from "path" @@ -10,7 +12,7 @@ import { commit, 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, open } from "./util" import { wrapper } from "./wrapper" /** @@ -100,6 +102,15 @@ export const openInExistingInstance = async (args: DefaultedArgs, socketPath: st logger.error("Please specify at least one file or folder") process.exit(1) } + if (args.wait) { + if (pipeArgs.fileURIs.length === 0) { + logger.error("--wait requires at least one file") + process.exit(1) + } + pipeArgs.waitMarkerFilePath = path.join(os.tmpdir(), `code-server-wait-${crypto.randomBytes(8).toString("hex")}`) + await fs.writeFile(pipeArgs.waitMarkerFilePath, "") + } + let failed = false const vscode = http.request( { path: "/", @@ -114,9 +125,17 @@ export const openInExistingInstance = async (args: DefaultedArgs, socketPath: st ) vscode.on("error", (error: unknown) => { logger.error("got error from Code", field("error", error)) + failed = true }) vscode.write(JSON.stringify(pipeArgs)) vscode.end() + if (pipeArgs.waitMarkerFilePath) { + // Code deletes the marker file once the opened files are closed. + while (!failed && (await isFile(pipeArgs.waitMarkerFilePath))) { + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + await fs.rm(pipeArgs.waitMarkerFilePath, { force: true }) + } } 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..9affc5c8b783 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,61 @@ 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)) + + 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 new Promise((resolve) => setTimeout(resolve, 1500)) + expect(returned).toBe(false) + + // Code deletes the marker once the file is closed. + await fs.rm(marker!) + await opening + expect(returned).toBe(true) + } finally { + if (pipeArgs?.waitMarkerFilePath) { + await fs.rm(pipeArgs.waitMarkerFilePath, { force: true }) + } + server.close() + } + }) + }) })