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
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- Files, selected Review file content, and `@` file autocomplete refresh after an external local change is detected at an attention boundary.
- Review compares the live filesystem again. Last turn remains the recorded agent comparison, so an external edit never gains agent attribution.
- An open Mcode Browser preview on a local loopback address reloads. Remote pages do not reload.
- The server tracks one dirty-set fingerprint per workspace scope. A `file.refresh` RPC diffs the current `git status` against that baseline and broadcasts `files.changed` only when the set moved.
- The server tracks one dirty-set fingerprint per workspace scope. A `file.refresh` RPC diffs the current `git status` against that baseline and broadcasts `files.changed` only when the set moved. Untracked directories are fingerprinted per file (`git status --untracked-files=all`), so adding or removing a file inside an already-untracked `?? dir/` still moves the fingerprint.
- Clients call `file.refresh` on attention boundaries: window focus, panel mount, thread scope change, and `@` picker open. There is no ambient filesystem watch.

## How to get to it (user or client POV)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
import * as NodePath from "node:path";
import * as NodeChildProcess from "node:child_process";
import { afterEach, expect, it } from "vitest";
import "reflect-metadata";
import { FileService } from "../file-service.js";
import { RealGitExecutor } from "../../git/execution/real-git-executor.js";

const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) NodeFS.rmSync(dir, { recursive: true, force: true });
});

function fixture() {
const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "mcode-server-audit-8384-"));
dirs.push(root);
const git = (...args: string[]) =>
NodeChildProcess.execFileSync("git", ["-C", root, ...args], { encoding: "utf8", timeout: 10000 }).trim();
git("init", "-b", "main");
git("config", "commit.gpgSign", "false");
git("config", "core.hooksPath", root);
git("config", "user.name", "Audit Fixture");
git("config", "user.email", "audit@example.invalid");
const repo = { findById: () => ({ path: root }) };
const executor = new RealGitExecutor();
const files = new FileService(
repo as never,
{ findById: () => null } as never,
{ resolveWorkingDir: () => root } as never,
executor,
{ platform: process.platform } as never,
);
return { root, git, files };
}

it("refresh invalidates files added inside an already untracked directory", async () => {
const { root, files } = fixture();
NodeFS.mkdirSync(NodePath.join(root, "new-folder"));
NodeFS.writeFileSync(NodePath.join(root, "new-folder", "first.ts"), "first");
await files.refresh("fixture");
NodeFS.writeFileSync(NodePath.join(root, "new-folder", "second.ts"), "second");
const delta = await files.refresh("fixture");
expect(delta).toEqual({ changedPaths: ["new-folder/second.ts"], wholeWorkspace: false });
});

it("refresh invalidates files removed from inside an untracked directory", async () => {
const { root, files } = fixture();
NodeFS.mkdirSync(NodePath.join(root, "new-folder"));
NodeFS.writeFileSync(NodePath.join(root, "new-folder", "first.ts"), "first");
NodeFS.writeFileSync(NodePath.join(root, "new-folder", "second.ts"), "second");
await files.refresh("fixture");
NodeFS.unlinkSync(NodePath.join(root, "new-folder", "second.ts"));
const delta = await files.refresh("fixture");
expect(delta).toEqual({ changedPaths: ["new-folder/second.ts"], wholeWorkspace: false });
});

it("refresh invalidates files added inside nested untracked directories", async () => {
const { root, files } = fixture();
NodeFS.mkdirSync(NodePath.join(root, "outer", "inner"), { recursive: true });
NodeFS.writeFileSync(NodePath.join(root, "outer", "inner", "first.ts"), "first");
await files.refresh("fixture");
NodeFS.mkdirSync(NodePath.join(root, "outer", "inner", "deep"), { recursive: true });
NodeFS.writeFileSync(NodePath.join(root, "outer", "inner", "deep", "second.ts"), "second");
const delta = await files.refresh("fixture");
expect(delta).toEqual({ changedPaths: ["outer/inner/deep/second.ts"], wholeWorkspace: false });
});

it("refresh stays silent when the untracked file set is unchanged", async () => {
const { root, files } = fixture();
NodeFS.mkdirSync(NodePath.join(root, "new-folder"));
NodeFS.writeFileSync(NodePath.join(root, "new-folder", "first.ts"), "first");
await files.refresh("fixture");
expect(await files.refresh("fixture")).toBeNull();
});

it("refresh stays silent for ignored files inside an untracked directory", async () => {
const { root, files } = fixture();
NodeFS.writeFileSync(NodePath.join(root, ".gitignore"), "*.log\n");
NodeFS.mkdirSync(NodePath.join(root, "new-folder"));
NodeFS.writeFileSync(NodePath.join(root, "new-folder", "first.ts"), "first");
await files.refresh("fixture");
NodeFS.writeFileSync(NodePath.join(root, "new-folder", "debug.log"), "x");
expect(await files.refresh("fixture")).toBeNull();
});

it("refresh stays silent when an untracked directory is staged", async () => {
const { root, files, git } = fixture();
NodeFS.mkdirSync(NodePath.join(root, "new-folder"));
NodeFS.writeFileSync(NodePath.join(root, "new-folder", "first.ts"), "first");
await files.refresh("fixture");
// The fingerprint tracks the dirty path set, not index state: `?? f` -> `A f`
// keeps the same path, matching single-file staging which never invalidated.
git("add", "new-folder");
expect(await files.refresh("fixture")).toBeNull();
});

it("refresh expands untracked directories even when repo config disables them", async () => {
const { root, files, git } = fixture();
git("config", "status.showUntrackedFiles", "no");
NodeFS.mkdirSync(NodePath.join(root, "new-folder"));
NodeFS.writeFileSync(NodePath.join(root, "new-folder", "first.ts"), "first");
await files.refresh("fixture");
NodeFS.writeFileSync(NodePath.join(root, "new-folder", "second.ts"), "second");
const delta = await files.refresh("fixture");
expect(delta).toEqual({ changedPaths: ["new-folder/second.ts"], wholeWorkspace: false });
});
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ describe("FileService.refresh", () => {
wholeWorkspace: false,
});
expect(exec).toHaveBeenCalledTimes(3);
expect(exec).toHaveBeenLastCalledWith(["status", "--porcelain"], { cwd: "C:/workspace" });
expect(exec).toHaveBeenLastCalledWith(
["status", "--porcelain", "--untracked-files=all"],
{ cwd: "C:/workspace" },
);
});

it("tracks thread scopes independently", async () => {
Expand All @@ -55,6 +58,22 @@ describe("FileService.refresh", () => {
await expect(service.refresh("workspace-1")).resolves.toBeNull();
});

it("reports wholeWorkspace when a delta exceeds the changed-path cap", async () => {
const exec = vi
.fn()
.mockResolvedValueOnce({ stdout: "" })
.mockResolvedValueOnce({
stdout: Array.from({ length: 101 }, (_, i) => `?? dir/file-${i}.ts`).join("\n"),
});
const { service } = makeService({ exec });

await expect(service.refresh("workspace-1")).resolves.toBeNull();
await expect(service.refresh("workspace-1")).resolves.toEqual({
changedPaths: [],
wholeWorkspace: true,
});
});

it("fingerprints the bounded listing when the scope is not a git repository", async () => {
const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "file-service-"));
try {
Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/features/projects/files/file-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ export class FileService {
* Runs one bounded `git status` for the scope and reports paths whose
* dirty-set fingerprint moved since the previous refresh. The first call
* only records the baseline; callers emit `files.changed` on real deltas.
* `--untracked-files=all` expands untracked directories to their files:
* default status output collapses them to `?? dir/`, which hides file
* additions and removals inside the directory from the fingerprint.
* Non-git scopes fingerprint the bounded directory listing instead.
* Returns null when the fingerprint is unchanged.
*/
Expand All @@ -74,7 +77,10 @@ export class FileService {

let paths: string[];
try {
const { stdout } = await this.gitExecutor.exec(["status", "--porcelain"], { cwd });
const { stdout } = await this.gitExecutor.exec(
["status", "--porcelain", "--untracked-files=all"],
{ cwd },
);
paths = parsePorcelainPaths(stdout);
} catch {
// Non-git folders fingerprint the same bounded listing `list` falls back to.
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/transport/ws-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ vi.mock("@/transport", () => ({
getTransport: vi.fn(),
}));

vi.mock("@/components/chat/useFileAutocomplete", async (importOriginal) => ({
...(await importOriginal<typeof import("@/components/chat/useFileAutocomplete")>()),
clearFileListCache: vi.fn(),
}));

import { pushEmitter } from "./ws-transport";
import { startPushListeners, stopPushListeners } from "./ws-events";
import { useWorkspaceStore } from "@/features/projects/state/workspaceStore";
Expand All @@ -18,6 +23,7 @@ import { onPtyExit } from "@/features/terminal/adapters/pty-data-registry";
import { useProjectActionStore } from "@/features/projects/environment/state/project-action-store";
import { useThreadStartupStore } from "@/features/thread-startup";
import { buildVolatileItems } from "@/features/conversation/messages/virtual-items";
import { clearFileListCache } from "@/components/chat/useFileAutocomplete";

function makeThread(overrides: Partial<Thread> = {}): Thread {
return {
Expand Down Expand Up @@ -380,6 +386,42 @@ describe("ws-events turn.persisted Review invalidation", () => {
});
});

describe("ws-events files.changed", () => {
beforeEach(() => {
useDiffStore.setState({ diffRevisionByScope: {} });
vi.mocked(clearFileListCache).mockClear();
});

afterEach(() => stopPushListeners());

it("invalidates the file autocomplete cache and bumps the diff revision for the pushed scope", () => {
startPushListeners();

pushEmitter.emit("files.changed", {
workspaceId: "ws-1",
threadId: "thread-1",
changedPaths: ["new-folder/second.ts"],
wholeWorkspace: false,
});

expect(clearFileListCache).toHaveBeenCalledWith("ws-1", "thread-1");
expect(useDiffStore.getState().diffRevisionByScope["thread-1"]).toBe(1);
});

it("falls back to the workspace scope when the push carries no threadId", () => {
startPushListeners();

pushEmitter.emit("files.changed", {
workspaceId: "ws-1",
changedPaths: ["new-folder/second.ts"],
wholeWorkspace: false,
});

expect(clearFileListCache).toHaveBeenCalledWith("ws-1", undefined);
expect(useDiffStore.getState().diffRevisionByScope["ws-1"]).toBe(1);
});
});

describe("ws-events terminal.exit", () => {
afterEach(() => {
stopPushListeners();
Expand Down
Loading