Skip to content
Open
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: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/node/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -305,6 +306,10 @@ export const options: Options<Required<UserProvidedArgs>> = {
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." },
Expand Down Expand Up @@ -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]}`)
Expand Down
21 changes: 20 additions & 1 deletion src/node/main.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"

/**
Expand Down Expand Up @@ -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: "/",
Expand All @@ -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 (
Expand Down
8 changes: 8 additions & 0 deletions test/unit/node/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
59 changes: 59 additions & 0 deletions test/unit/node/main.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<void>((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<void>((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()
}
})
})
})