diff --git a/README.md b/README.md index 0a53ef6..ae57d83 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,16 @@ checkout are the only two ways to run it. (`scripts/release.ts`'s TSDoc, "Why there is no extra bundler config for embedding"), so nothing else needs to be on the machine. +`` does not need to exist yet: a file argument that isn't there on +disk opens as a new, empty, editable buffer, and saving it (`ctrl+s`) +creates the file — `tecode README2.md` on a fresh checkout opens an +empty `README2.md` you can start typing into immediately (Req 5.6, Req +12.4, Issue #88). This only applies when the path's parent directory +already exists and the argument doesn't end in a trailing `/` or `\` +(which always means "directory", never "file"); a typo'd deep path +(`notes/nested/todo.md` with no `notes/nested` directory) still warns +and starts with no file open, exactly as it always has. + macOS Gatekeeper will likely quarantine an unsigned downloaded binary on first run (`xattr -d com.apple.quarantine tecode-darwin-` clears it, or use the Finder's "Open" right-click override) — this repo does not diff --git a/design.md b/design.md index a65ed94..ef8a005 100644 --- a/design.md +++ b/design.md @@ -197,6 +197,12 @@ class Document { Owns the `Map`, exposes `tecode.workspace.openDocument/documents` and the open/close/save events, resolves language IDs via the language registry on open, and fires `onLanguage:*` activation events. Save writes atomically (write temp + rename) and clears `dirty`. +**Opening a path that doesn't exist yet** (*Req 5.6*, Issue #88): `openDocument` treats an `ENOENT` from its initial `stat` as "a new file the user hasn't saved yet", not a failure — it builds the `Document` with `text: ""`, `readonly: false` instead of rejecting. Because `dirty` starts `false` and only flips on a real edit, an untouched new buffer prompts no save-on-quit confirmation and writes nothing to disk merely from having been opened — but `save()` itself does not gate on `dirty` (a pre-existing property, not new here), so an explicit save on a still-untouched new document does create an empty file. Every other `stat`/read failure (`EACCES`, `EIO`, ...) keeps the pre-Issue-#88 contract: reject and report through `log`/`sink`, never silently degrading to an empty buffer — that would let a save quietly overwrite a file the caller was never able to read. + +This ENOENT branch is unconditional: it does not itself check whether the path's parent directory exists. `cli/argv.ts`'s `resolveStartupTarget` (§3) layers a stricter guard in front of it for CLI startup specifically — see below — but `DocumentManager` stays the simple, single-policy primitive every caller (CLI startup and `tecode.workspace.openDocument` alike) shares; a path with a missing parent still gets a clear, specific error, just at `save()` time (the temp-file write's own `ENOENT`) rather than at `openDocument()` time. + +**CLI startup's stricter guard** (`resolveStartupTarget`, Req 12.4, Issue #88): a non-existent positional argument opens as a new file only when (a) the argv token itself does not end in `/` or `\` — a trailing separator unambiguously names a directory, and `path.resolve` would otherwise silently discard that signal — and (b) `dirname(resolved)` exists and is itself a directory. Failing either check falls back to the pre-existing "warn and start with no workspace" behavior. The asymmetry with `DocumentManager` above is deliberate: a CLI entry point can react to a typo'd deep path (`a/b/c.txt` with no `a/b`) with an immediate warning naming the exact path, which is a better first signal than a silently-opened empty editor whose eventual save failure no longer even mentions the typo. + ## 8. UI Shell ### 8.1 Component tree diff --git a/packages/cli/src/argv.test.ts b/packages/cli/src/argv.test.ts index 8c3b29f..802509e 100644 --- a/packages/cli/src/argv.test.ts +++ b/packages/cli/src/argv.test.ts @@ -45,9 +45,19 @@ test("a relative path argument resolves against cwd", async () => { expect(target).toEqual({ workspaceRoot: join(dir, "sub") }); }); -test("a nonexistent path logs a warning and falls back to cwd", async () => { +test("a nonexistent path whose parent directory exists opens as a new file (Req 5.6, Issue #88)", async () => { dir = await mkdtemp(join(tmpdir(), "tecode-argv-")); - const missing = join(dir, "does-not-exist"); + const missing = join(dir, "does-not-exist.txt"); + + const log = createHostLog(); + const target = await resolveStartupTarget([missing], "/irrelevant", log); + expect(target).toEqual({ workspaceRoot: dir, initialFilePath: missing }); + expect(log.entries()).toEqual([]); +}); + +test("a nonexistent path whose PARENT directory is also missing logs a warning and falls back to cwd (Issue #88 design note)", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-argv-")); + const missing = join(dir, "no-such-parent", "does-not-exist.txt"); const log = createHostLog(); const target = await resolveStartupTarget([missing], "/fallback-cwd", log); @@ -59,6 +69,68 @@ test("a nonexistent path logs a warning and falls back to cwd", async () => { expect(entries[0]?.error.message).toContain(missing); }); +test("a nonexistent path whose immediate parent is a FILE (not a directory) logs a warning and falls back to cwd", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-argv-")); + const notADir = join(dir, "notadir.txt"); + await writeFile(notADir, "x", "utf8"); + const missing = join(notADir, "does-not-exist.txt"); + + const log = createHostLog(); + const target = await resolveStartupTarget([missing], "/fallback-cwd", log); + expect(target).toEqual({ workspaceRoot: "/fallback-cwd" }); + expect(log.entries().length).toBe(1); +}); + +test("a nonexistent DIRECTORY-shaped path (trailing slash) logs a warning and falls back to cwd rather than opening as a file (Issue #88 design note)", async () => { + dir = await mkdtemp(join(tmpdir(), "tecode-argv-")); + const missingDirArg = join(dir, "newdir") + "/"; + + const log = createHostLog(); + const target = await resolveStartupTarget([missingDirArg], "/fallback-cwd", log); + expect(target).toEqual({ workspaceRoot: "/fallback-cwd" }); + expect(log.entries().length).toBe(1); +}); + +test.skipIf(process.platform === "win32")( + "on POSIX a trailing backslash is an ordinary filename character, so such a path still opens as a new file (CodeRabbit finding on PR #89)", + async () => { + // `\\` is a separator only on Windows. On POSIX `path.resolve` leaves it + // inside the basename — `resolve("/tmp", "draft\\")` is `/tmp/draft\\`, + // whose `dirname` is `/tmp` — so `draft\\` names a perfectly valid file + // that does not exist yet, and rejecting it would warn-and-fall-back on + // a path the user could legitimately create. + dir = await mkdtemp(join(tmpdir(), "tecode-argv-")); + const backslashName = join(dir, "draft") + "\\"; + + const log = createHostLog(); + const target = await resolveStartupTarget([backslashName], "/fallback-cwd", log); + expect(target).toEqual({ workspaceRoot: dir, initialFilePath: backslashName }); + expect(log.entries().length).toBe(0); + }, +); + +test("an EACCES (or any non-ENOENT) stat failure still logs a warning and falls back to cwd, never opens a new file", async () => { + // Deliberately makes the PARENT directory's stat succeed (return a real + // directory) while only the target path's own stat fails with EACCES — + // if `resolveStartupTarget` ever stopped checking the error code before + // trying the new-file path, this would open `some/path` as a new file + // instead of warning, since the parent-exists guard alone would pass. + const log = createHostLog(); + const fakeFs = { + stat: async (path: string) => { + if (path.endsWith("path")) { + const err = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); + throw err; + } + return { isDirectory: () => true }; + }, + }; + const target = await resolveStartupTarget(["some/path"], "/fallback-cwd", log, fakeFs); + expect(target).toEqual({ workspaceRoot: "/fallback-cwd" }); + expect(log.entries().length).toBe(1); + expect(log.entries()[0]?.level).toBe("warning"); +}); + test("only the first non-flag token is treated as the positional argument", async () => { dir = await mkdtemp(join(tmpdir(), "tecode-argv-")); const log = createHostLog(); diff --git a/packages/cli/src/argv.ts b/packages/cli/src/argv.ts index 3080584..9c854a2 100644 --- a/packages/cli/src/argv.ts +++ b/packages/cli/src/argv.ts @@ -54,6 +54,79 @@ function describeError(err: unknown): string { } } +/** Extract an errno-style `code` (e.g. `"ENOENT"`) from a caught unknown, + * or `undefined` when it carries none. Duplicated per-module rather than + * imported (matches `@tecode/core`'s own convention of a private + * `errorCode` in each module that needs one — e.g. + * `buffer/documentManager.ts`'s, `config/service.ts`'s — instead of a + * shared export; this module is `cli`, which may import `@tecode/core`, + * but this helper is a two-line leaf with no state, so duplicating it + * avoids a public export whose only purpose would be this one call). */ +function errorCode(err: unknown): string | undefined { + if (typeof err === "object" && err !== null && "code" in err) { + const code = (err as { code?: unknown }).code; + if (typeof code === "string") return code; + } + return undefined; +} + +/** + * Decide whether a non-existent `resolved` path (Req 5.6, Issue #88) + * should open as a brand-new, empty in-memory document rather than + * degrade to "no workspace" — called from {@link resolveStartupTarget}'s + * catch block only when the initial `stat` failed with `ENOENT`. Two + * guards gate this, both chosen over the alternative of opening + * unconditionally and deferring to a save-time error (which is what + * `buffer/documentManager.ts`'s `openDocument` does instead, for its own, + * different reasons — see that function's TSDoc): + * + * - **Directory-shaped positional**: `raw` (the ORIGINAL argv token, not + * `resolved`) ending in `/` or `\` unambiguously means the user meant a + * directory — `path.resolve` normalizes away a trailing separator, so + * by the time `resolved` exists that signal is already gone, which is + * why this checks `raw`. A file can never be opened at a path spelled + * with a trailing separator, so `tecode newdir/` on a non-existent + * `newdir` degrades to the ordinary "does not exist" warning rather + * than silently opening `newdir` (no trailing slash) as a file. + * - **Missing parent directory** (`a/b/c.txt` where `a/b` doesn't exist): + * opening this as a new file would let a typo'd deep path silently + * open an empty editor with nothing on screen to say anything is + * wrong — discoverable only later, when a save fails for a reason + * (`ENOENT` on the temp-file write, inside a directory that was never + * the one the user actually mistyped) that no longer even names the + * original mistake. A startup warning that names the exact path right + * away is a far better first signal for a CLI entry point, so this + * only treats `resolved` as a new file when `dirname(resolved)` both + * exists and is itself a directory. + * + * Returns `undefined` (caller falls through to its existing warning) when + * either guard fails, or the parent's own `stat` call fails for any + * reason. + */ +async function tryResolveAsNewFile( + fs: ArgvResolutionFs, + raw: string, + resolved: string, +): Promise { + // `/` is a separator everywhere. `\` is one only on Windows: on POSIX it + // is an ordinary filename character, so `tecode 'draft\'` names a + // perfectly valid file that does not exist yet, and rejecting it here + // would warn-and-fall-back on a path the user could legitimately create + // (CodeRabbit finding on PR #89 — `path.resolve("/tmp", "draft\\")` gives + // `/tmp/draft\`, whose `dirname` is `/tmp`, not a directory named + // `draft`). + if (raw.endsWith("/")) return undefined; + if (process.platform === "win32" && raw.endsWith("\\")) return undefined; + const parent = dirname(resolved); + try { + const parentStats = await fs.stat(parent); + if (!parentStats.isDirectory()) return undefined; + } catch { + return undefined; + } + return { workspaceRoot: parent, initialFilePath: resolved }; +} + /** Every index in `argv` holding a `--config` flag's value — i.e. the token * immediately after each `--config` occurrence (Issue #81 Phase 1). Shared * by {@link resolveConfigDirOverride} and {@link resolveStartupTarget} so @@ -121,11 +194,18 @@ export function resolveConfigDirOverride(argv: readonly string[]): string | unde * job, called separately by `main.ts`. * * Never throws (matches `core`'s never-throwing service boundaries): a - * path that does not exist, or can't be `stat`-ed, is reported to `log` as - * a warning and treated as if no argument had been given (`cwd`) — a - * typo'd path should degrade to an empty workspace rather than abort - * startup, the same "continue starting up" spirit Req 2.4 applies to a bad - * extension. + * path that can't be `stat`-ed for a reason other than "missing" is + * reported to `log` as a warning and treated as if no argument had been + * given (`cwd`) — a bad path should degrade to an empty workspace rather + * than abort startup, the same "continue starting up" spirit Req 2.4 + * applies to a bad extension. + * + * **A path that does not exist (`ENOENT`) opens as a new, empty document + * instead** (Req 5.6, Issue #88), UNLESS {@link tryResolveAsNewFile}'s two + * guards say otherwise (a directory-shaped positional, or a missing + * parent directory) — in either of those cases this still falls through + * to the warning-and-`cwd` degradation above, exactly as before Issue + * #88. See {@link tryResolveAsNewFile}'s TSDoc for the full reasoning. */ export async function resolveStartupTarget( argv: readonly string[], @@ -145,6 +225,10 @@ export async function resolveStartupTarget( if (stats.isDirectory()) return { workspaceRoot: resolved }; return { workspaceRoot: dirname(resolved), initialFilePath: resolved }; } catch (cause) { + if (errorCode(cause) === "ENOENT") { + const asNewFile = await tryResolveAsNewFile(fs, positional, resolved); + if (asNewFile) return asNewFile; + } log.append("warning", { message: `Startup path "${resolved}" does not exist or could not be read (${describeError(cause)}); starting with no workspace.`, path: resolved, diff --git a/packages/cli/src/main.test.ts b/packages/cli/src/main.test.ts index 4338822..efe3a1a 100644 --- a/packages/cli/src/main.test.ts +++ b/packages/cli/src/main.test.ts @@ -1,9 +1,18 @@ import { expect, test } from "bun:test"; -import { mkdir, mkdtemp, readdir as nodeReaddir, rm, stat as nodeStat, writeFile } from "node:fs/promises"; +import { + mkdir, + mkdtemp, + readdir as nodeReaddir, + readFile, + rm, + stat as nodeStat, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { BASE_THEME_ID, + createHostLog, EXTENSIONS_RELOAD_COMMAND_ID, getUserExtensionsDir, KEYBINDINGS_ENSURE_FILE_COMMAND_ID, @@ -22,6 +31,7 @@ import { type DiscoveryFs, } from "@tecode/core"; import pkg from "../package.json"; +import { resolveStartupTarget } from "./argv"; import { buildAssemblyRoot, runDeferredPhase } from "./main"; /** A {@link DiscoveryFs} backed by the real filesystem, except the real @@ -347,6 +357,79 @@ test("runDeferredPhase loads a workspace extension, activates it on startup, wir } }, 15_000); +test("Issue #88 end to end: `tecode README2.md` on a non-existent path opens an editable empty buffer, and saving it creates the real file on disk — through argv resolution, not just DocumentManager in isolation", async () => { + const homeDir = await mkdtemp(join(tmpdir(), "tecode-cli-home-")); + const workspaceDir = await mkdtemp(join(tmpdir(), "tecode-cli-ws-")); + const savedHome = process.env["HOME"]; + const savedAppData = process.env["APPDATA"]; + process.env["HOME"] = homeDir; + process.env["APPDATA"] = homeDir; + + const targetFile = join(workspaceDir, "README2.md"); + // The load-bearing assumption under test: this file does NOT exist yet. + await expect(nodeStat(targetFile)).rejects.toBeDefined(); + + let root: ReturnType; + try { + // Real argv resolution (Req 5.6, Req 12.4, Issue #88) — the whole + // point of this test is exercising `resolveStartupTarget` too, not + // just handing `documentManager` a pre-resolved path. + const log = createHostLog(); + const target = await resolveStartupTarget([targetFile], workspaceDir, log); + expect(target).toEqual({ workspaceRoot: workspaceDir, initialFilePath: targetFile }); + expect(log.entries()).toEqual([]); + + root = buildAssemblyRoot(target.workspaceRoot); + try { + await root.config.ready; + + const { extensionHost, loadResult } = await runDeferredPhase(root, { + initialFilePath: target.initialFilePath, + fs: createHermeticDiscoveryFs(), + builtins: [], + }); + expect(loadResult.skipped).toEqual([]); + + // Opened as a new, empty, non-dirty document — not "No editor open." + const uri = pathToUri(targetFile); + const doc = root.documents.documents.find((d) => d.uri === uri); + expect(doc).toBeDefined(); + expect(doc!.getText()).toBe(""); + expect(doc!.readonly).toBe(false); + expect(doc!.dirty).toBe(false); + + // Opening alone must not have touched the real filesystem. + await expect(nodeStat(targetFile)).rejects.toBeDefined(); + + // Edit, then save through the real DocumentManager/real fs. + doc!.applyEdits([ + { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + newText: "# README2\n", + }, + ]); + const saved = await root.documents.save(uri); + expect(saved).toBe(true); + expect(doc!.dirty).toBe(false); + + // The real file now exists on disk with the expected content. + const onDisk = await readFile(targetFile, "utf8"); + expect(onDisk).toBe("# README2\n"); + + await extensionHost.disposeAll(); + } finally { + root.config.dispose(); + } + } finally { + if (savedHome === undefined) delete process.env["HOME"]; + else process.env["HOME"] = savedHome; + if (savedAppData === undefined) delete process.env["APPDATA"]; + else process.env["APPDATA"] = savedAppData; + await rm(homeDir, { recursive: true, force: true }); + await rm(workspaceDir, { recursive: true, force: true }); + } +}, 15_000); + test("runDeferredPhase reports a bad extension without failing startup (Req 2.4)", async () => { const homeDir = await mkdtemp(join(tmpdir(), "tecode-cli-home-")); const workspaceDir = await mkdtemp(join(tmpdir(), "tecode-cli-ws-")); diff --git a/packages/core/src/buffer/documentManager.test.ts b/packages/core/src/buffer/documentManager.test.ts index 2767b7b..932a526 100644 --- a/packages/core/src/buffer/documentManager.test.ts +++ b/packages/core/src/buffer/documentManager.test.ts @@ -142,19 +142,106 @@ describe("DocumentManager.openDocument (Req 5.5)", () => { expect(manager.documents).toHaveLength(1); }); - test("opening a nonexistent file rejects the promise AND reports through log/sink", async () => { + test("opening a nonexistent file (ENOENT) opens a new, empty, non-dirty document instead of failing (Req 5.6, Issue #88)", async () => { const path = join(dir, "missing.txt"); const { log, sink, errors } = baseDeps(); const manager = createDocumentManager({ log, sink }); - await expect(manager.openDocument(pathToUri(path))).rejects.toBeDefined(); + const doc = await manager.openDocument(pathToUri(path)); + expect(doc.getText()).toBe(""); + expect(doc.readonly).toBe(false); + expect(doc.dirty).toBe(false); + expect(errors).toHaveLength(0); + expect(log.entries().filter((e) => e.level === "error")).toHaveLength(0); + expect(manager.documents).toHaveLength(1); + }); + + test("opening a nonexistent file leaves the document non-dirty, but an EXPLICIT save() still creates the (empty) file (Issue #88 design note)", async () => { + // `saveNow` never gates on `dirty` — it always performs the write, + // for every document, new-file or not (that's a pre-existing, + // unrelated property of `saveNow`, not something Issue #88 + // introduces). So `dirty` staying `false` on open only guarantees + // "no save-changes prompt on quit for an untouched buffer" and "no + // autosave writes it" — it does NOT mean an explicit `save()` call + // is a no-op. This test pins that down: nothing on disk changes + // merely from opening, but calling `save()` — even on a document + // nobody ever typed into — still creates the empty file. + const path = join(dir, "untouched.txt"); + const { log, sink } = baseDeps(); + const manager = createDocumentManager({ log, sink }); + + const uri = pathToUri(path); + const doc = await manager.openDocument(uri); + expect(doc.dirty).toBe(false); + await expect(fsStat(path)).rejects.toBeDefined(); // opening alone created nothing + + const ok = await manager.save(uri); + expect(ok).toBe(true); + const written = await readFile(path, "utf8"); + expect(written).toBe(""); + }); + + test("an EACCES (non-ENOENT) open failure still rejects the promise AND reports through log/sink — never becomes an empty buffer (Issue #88)", async () => { + const uri = pathToUri(join(dir, "denied.txt")); + const { log, sink, errors } = baseDeps(); + + const accessDeniedFs: DocumentManagerFs = { + stat: async () => { + const err = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); + throw err; + }, + readFile: (p, enc) => readFile(p, enc), + writeFile: (p, data, opts) => fsWriteFile(p, data, opts), + chmod: (p, mode) => fsChmod(p, mode), + rename: (a, b) => fsRename(a, b), + unlink: (p) => fsUnlink(p), + }; + const manager = createDocumentManager({ log, sink, fs: accessDeniedFs }); + + await expect(manager.openDocument(uri)).rejects.toBeDefined(); expect(errors).toHaveLength(1); - expect(errors[0]!.path).toBe(pathToUri(path)); + expect(errors[0]!.path).toBe(uri); const errorEntries = log.entries().filter((e) => e.level === "error"); expect(errorEntries).toHaveLength(1); expect(manager.documents).toHaveLength(0); }); + test("a nonexistent path with a MISSING PARENT DIRECTORY still opens as a new file (unconditional on ENOENT), and a later save fails with a clear error naming the path (Issue #88 design note)", async () => { + // Unlike `cli/argv.ts`'s `resolveStartupTarget` (which refuses to + // open a new file when the parent directory doesn't exist, to give + // CLI startup an early warning naming the exact typo'd path), + // `DocumentManager.openDocument` is a lower-level primitive used by + // every caller (including extensions via `tecode.workspace. + // openDocument`) and stays simple: ANY `ENOENT` opens a new, empty + // document, parent or no parent. A genuinely broken path just + // surfaces its clear error one step later, at `save()` time, instead + // of at `openDocument()` time. + const path = join(dir, "no-such-parent", "deep.txt"); + const { log, sink, errors } = baseDeps(); + const manager = createDocumentManager({ log, sink }); + const uri = pathToUri(path); + + const doc = await manager.openDocument(uri); + expect(doc.getText()).toBe(""); + expect(doc.readonly).toBe(false); + expect(doc.dirty).toBe(false); + + doc.applyEdits([ + { + range: { start: { line: 0, character: 0 }, end: { line: 0, character: 0 } }, + newText: "hi", + }, + ]); + + const ok = await manager.save(uri); + expect(ok).toBe(false); + expect(doc.dirty).toBe(true); // the failed save must not clear dirty + expect(errors.length).toBeGreaterThan(0); + expect(errors.some((e) => e.path === uri)).toBe(true); + const errorEntries = log.entries().filter((e) => e.level === "error"); + expect(errorEntries.some((e) => e.error.message.includes("ENOENT"))).toBe(true); + }); + test("a throwing onLanguageActivation callback does not break open", async () => { const path = join(dir, "ok.txt"); await writeFile(path, "text", "utf8"); diff --git a/packages/core/src/buffer/documentManager.ts b/packages/core/src/buffer/documentManager.ts index 7f3ada0..f3ebd78 100644 --- a/packages/core/src/buffer/documentManager.ts +++ b/packages/core/src/buffer/documentManager.ts @@ -95,13 +95,42 @@ export interface DocumentManager { * registers it, fires `onDidOpen`, then calls `onLanguageActivation` * (guarded). * - * A read failure (missing file, permission error, stat failure, ...) - * both rejects the returned promise AND is reported through + * **A path that does not exist yet opens as a new, empty, non-dirty + * document instead of failing** (Req 5.6, Issue #88): when the initial + * `stat` fails with `ENOENT` specifically, this is treated as "a file + * the user hasn't saved yet" rather than an error — `text` is `""` and + * `readonly` is `false`. `createDocument`'s `dirty` starts `false` and + * only flips on an actual edit (`document.ts`), so an untouched new + * buffer never prompts a save-changes confirmation on quit, and saving + * it without ever typing into it still writes nothing to disk + * (`saveNow` always performs its write — it does not itself gate on + * `dirty` — so this guarantee comes entirely from the buffer starting + * clean, not from `save()` skipping a no-op). + * + * Deliberately UNCONDITIONAL on `ENOENT` — unlike `cli/argv.ts`'s + * `resolveStartupTarget`, this does NOT also require `dirname(path)` to + * exist. `resolveStartupTarget` layers its own stricter "parent must + * exist" guard on top, because CLI startup can react to a typo'd deep + * path with an immediate, specific warning instead of silently opening + * an editor. This lower-level primitive is shared by every caller + * (including `tecode.workspace.openDocument`, called by extensions with + * arbitrary paths, not just CLI startup) and stays simple: a path whose + * parent genuinely doesn't exist still surfaces a clear, specific + * error — just deferred to `save()` time instead of `openDocument` + * time (the temp-file `writeFile` in `saveNow` fails with its own + * `ENOENT`, reported through `log`/`sink` exactly like any other save + * failure). + * + * Every OTHER read failure (`EACCES`, `EIO`, or a stat/read that fails + * for any reason besides "missing") keeps the pre-Issue-#88 contract + * exactly: both rejects the returned promise AND is reported through * `log`/`sink` — `openDocument` is an explicit, caller-awaited action * (unlike `applyEdits`, which the UI drives on every keystroke), so * the caller needs to know synchronously that it failed, while the * log/sink still get a durable record for the status bar and - * `developer.showLog`. + * `developer.showLog`. Silently opening a permission-denied path as an + * empty buffer instead would be worse than the failure it replaces: a + * later save would overwrite a file the user was never able to read. */ openDocument(uri: Uri): Promise; /** All currently open documents, as a fresh array snapshot. */ @@ -244,13 +273,21 @@ export function createDocumentManager(deps: DocumentManagerDeps): DocumentManage readonly = stat.size >= LARGE_FILE_THRESHOLD_BYTES; text = await fs.readFile(path, "utf8"); } catch (cause) { - const err: HostError = { - message: `Failed to open document: ${describeError(cause)}`, - path: uri, - }; - logSafely("error", err); - notifySafely(err); - throw cause; + if (errorCode(cause) !== "ENOENT") { + const err: HostError = { + message: `Failed to open document: ${describeError(cause)}`, + path: uri, + }; + logSafely("error", err); + notifySafely(err); + throw cause; + } + // ENOENT: a new file that doesn't exist on disk yet (Req 5.6, Issue + // #88) — see this function's TSDoc above for why this is + // unconditional and how a truly broken path still gets a clear + // error, just deferred to save() time. + text = ""; + readonly = false; } const languageId = resolveLanguageId(uri); diff --git a/requirements.md b/requirements.md index 3d02d68..94c429e 100644 --- a/requirements.md +++ b/requirements.md @@ -98,6 +98,7 @@ The following points were open in the draft specification and are resolved here 3. THE system SHALL emit `onDidChange`, `onDidSave`, `onDidOpen`, and `onDidClose` events for documents. 4. THE core SHALL implement undo/redo, and SHALL provide `document.transaction(fn)` so extensions can group multiple edits into a single undo step. 5. WHEN a file larger than 10 MB is opened, THE system SHALL open it read-only. +6. WHEN `openDocument` is given a `Uri` whose path does not exist on disk (`ENOENT`), THE system SHALL open it as a new, empty, non-dirty document (`readonly: false`) rather than failing — saving it SHALL create the file. Every other read failure (permission denied, I/O error, or any other non-`ENOENT` `stat`/read error) SHALL still reject the open and report it through the log/status sink exactly as before, and SHALL NOT be opened as an empty document (Issue #88). ### Requirement 6: UI Shell and Slots @@ -194,6 +195,7 @@ The following points were open in the draft specification and are resolved here 1. WHEN tecode is launched, THE system SHALL proceed in this order: load configuration; discover extensions; register manifest declarations without executing extension code; activate extensions lazily per their activation events; render the UI shell; then open the initial file or directory given on the command line. 2. THE UI shell SHALL render within 100 ms of launch, with extension loading deferred so it does not block first paint. 3. WHEN tecode exits — whether by `SIGINT`/`SIGTERM` or by an interactive Ctrl+C while the terminal is in raw mode (which never delivers `SIGINT`, since raw mode disables signal generation) — THE system SHALL run the same shutdown sequence exactly once regardless of which of these triggers fired first: flush layout state (Requirement 6.4), dispose every core-owned service, and deactivate every extension (Requirement 2.6), all bounded by a timeout so a hung disposal cannot prevent the process from exiting. +4. WHEN the command-line path argument does not exist on disk, THE system SHALL open it as a new document per Requirement 5.6, PROVIDED its parent directory exists and the argument does not end in a path separator (which unambiguously names a directory, never a file); OTHERWISE THE system SHALL warn and start with no initial document, exactly as an unreadable path does today (Issue #88). ### Requirement 13: Non-Functional Requirements