@cueloop/daemon 0.1.0-alpha.66 → 0.1.0-alpha.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/api.test.ts +18 -0
- package/src/api.ts +43 -1
- package/src/capabilities.ts +8 -0
- package/src/client.ts +35 -0
- package/src/project-files.test.ts +124 -0
- package/src/project-files.ts +56 -0
- package/src/server.ts +21 -0
- package/src/validate.ts +6 -0
- package/src/working-tree.ts +37 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/daemon",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.67",
|
|
4
4
|
"description": "The cueloop session daemon and its client: socket protocol, atomic store, resumable waits",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
38
|
+
"@cueloop/schema": "0.1.0-alpha.67",
|
|
39
39
|
"@pierre/diffs": "^1.3.6",
|
|
40
40
|
"valibot": "^1.4.2"
|
|
41
41
|
},
|
package/src/api.test.ts
CHANGED
|
@@ -154,6 +154,24 @@ describe("session lifecycle", () => {
|
|
|
154
154
|
expect(core.sessionGet(session.id).workingCopy).toBeUndefined();
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
test("a thread rename sets the title, clears back to the default on empty, and survives a restart", () => {
|
|
158
|
+
// Arrange
|
|
159
|
+
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
|
160
|
+
|
|
161
|
+
// Act
|
|
162
|
+
core.sessionSetTitle(session.id, " Renamed thread ");
|
|
163
|
+
|
|
164
|
+
// Assert - trimmed and persisted
|
|
165
|
+
expect(core.sessionGet(session.id).artifact.meta.title).toBe("Renamed thread");
|
|
166
|
+
expect(new DaemonCore(home).sessionGet(session.id).artifact.meta.title).toBe("Renamed thread");
|
|
167
|
+
|
|
168
|
+
// Act - an empty title clears back to the derived default
|
|
169
|
+
core.sessionSetTitle(session.id, " ");
|
|
170
|
+
|
|
171
|
+
// Assert
|
|
172
|
+
expect(core.sessionGet(session.id).artifact.meta.title).toBeUndefined();
|
|
173
|
+
});
|
|
174
|
+
|
|
157
175
|
test("viewed paths merge, dedupe, clear on empty, and survive a daemon restart", () => {
|
|
158
176
|
// Arrange
|
|
159
177
|
const session = core.sessionCreate({ workspace: WS, artifact: PLAN });
|
package/src/api.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
switchBranch,
|
|
32
32
|
verdictAllows,
|
|
33
33
|
type Annotation,
|
|
34
|
+
type DiffFileStatus,
|
|
34
35
|
type HunkRejection,
|
|
35
36
|
type NewEntry,
|
|
36
37
|
type Artifact,
|
|
@@ -46,7 +47,9 @@ import { SessionStore, withHistory } from "./store";
|
|
|
46
47
|
import { pruneExpiredSessions, resolveCleanupPeriodDays } from "./retention";
|
|
47
48
|
import { HerdrTabStore, type HerdrTabHandle } from "./herdr-tab-store";
|
|
48
49
|
import { DiffWatcher } from "./diff-watcher";
|
|
49
|
-
import { workingTreeDiff } from "./working-tree";
|
|
50
|
+
import { workingTreeDiff, workingChangeList } from "./working-tree";
|
|
51
|
+
import { listProjectFiles, readProjectFile } from "./project-files";
|
|
52
|
+
import { resolveWorkspace } from "./review";
|
|
50
53
|
import { DaemonError } from "./errors";
|
|
51
54
|
|
|
52
55
|
/** What a share hands back: the notes and names it collected, and the removals it recorded. */
|
|
@@ -321,6 +324,45 @@ export class DaemonCore {
|
|
|
321
324
|
return session;
|
|
322
325
|
}
|
|
323
326
|
|
|
327
|
+
/** Rename a session's display title; an empty title clears it back to the derived default. */
|
|
328
|
+
sessionSetTitle(id: string, title: string): ReviewSession {
|
|
329
|
+
const session = this.mutable(id);
|
|
330
|
+
const trimmed = title.trim();
|
|
331
|
+
|
|
332
|
+
if (trimmed.length === 0) delete session.artifact.meta.title;
|
|
333
|
+
else session.artifact.meta.title = trimmed;
|
|
334
|
+
this.store.upsert(session);
|
|
335
|
+
// the title shows in the Threads sidebar, so a rename is an inbox change, not just a content edit
|
|
336
|
+
this.emit("inbox.changed", id);
|
|
337
|
+
|
|
338
|
+
return session;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Tracked, repo-relative file paths for the session's workspace; [] when it has no repo. */
|
|
342
|
+
projectFiles(id: string): Promise<string[]> {
|
|
343
|
+
return listProjectFiles(this.sessionGet(id).workspace.repoRoot);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** UTF-8 contents of a repo-relative file in the session's workspace, or null when it cannot be read safely. */
|
|
347
|
+
fileContents(id: string, path: string): Promise<string | null> {
|
|
348
|
+
return readProjectFile(this.sessionGet(id).workspace.repoRoot, path);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Tracked, repo-relative file paths for the git repo containing `cwd`; [] when it is not a repo. Owner-only. */
|
|
352
|
+
async repoFiles(cwd: string): Promise<string[]> {
|
|
353
|
+
return listProjectFiles((await resolveWorkspace(cwd)).repoRoot);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** UTF-8 contents of a repo-relative file in the repo containing `cwd`, or null when unreadable. Owner-only. */
|
|
357
|
+
async repoFileContents(cwd: string, path: string): Promise<string | null> {
|
|
358
|
+
return readProjectFile((await resolveWorkspace(cwd)).repoRoot, path);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Changed files (repo-relative path plus git status) in the working tree at `cwd`. Owner-only. */
|
|
362
|
+
repoChanges(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]> {
|
|
363
|
+
return workingChangeList(cwd);
|
|
364
|
+
}
|
|
365
|
+
|
|
324
366
|
sessionSetShareId(id: string, shareId: string): ReviewSession {
|
|
325
367
|
const session = this.mutable(id);
|
|
326
368
|
|
package/src/capabilities.ts
CHANGED
|
@@ -35,6 +35,14 @@ const PRIMITIVE_ROLES = {
|
|
|
35
35
|
"session.restoreBlock": "owner",
|
|
36
36
|
"session.curate": "owner",
|
|
37
37
|
"session.setViewed": "owner",
|
|
38
|
+
"session.setTitle": "owner",
|
|
39
|
+
// reading the local working tree is the owner's alone; a share carries no repo to read
|
|
40
|
+
"session.projectFiles": "owner",
|
|
41
|
+
"session.fileContents": "owner",
|
|
42
|
+
// the launch repo is the local owner's; a share has no local repo to browse
|
|
43
|
+
"repo.files": "owner",
|
|
44
|
+
"repo.fileContents": "owner",
|
|
45
|
+
"repo.changes": "owner",
|
|
38
46
|
// the tree is the owner's: collaborators comment on the branch a share follows
|
|
39
47
|
"session.navigate": "owner",
|
|
40
48
|
"session.branch": "owner",
|
package/src/client.ts
CHANGED
|
@@ -9,6 +9,7 @@ import * as v from "valibot";
|
|
|
9
9
|
import type {
|
|
10
10
|
Annotation,
|
|
11
11
|
Artifact,
|
|
12
|
+
DiffFileStatus,
|
|
12
13
|
HunkRejection,
|
|
13
14
|
ReviewSession,
|
|
14
15
|
VerdictKind,
|
|
@@ -78,6 +79,18 @@ export interface SessionClient {
|
|
|
78
79
|
/** Replace a diff review's reject decisions; the working copy follows. */
|
|
79
80
|
sessionCurate(id: string, rejections: HunkRejection[]): Promise<ReviewSession>;
|
|
80
81
|
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession>;
|
|
82
|
+
/** Rename a session's display title; an empty title restores the derived default. */
|
|
83
|
+
sessionSetTitle(id: string, title: string): Promise<ReviewSession>;
|
|
84
|
+
/** Tracked, repo-relative file paths for the session's workspace; a client with no local repo (a share) omits it. */
|
|
85
|
+
projectFiles?(sessionId: string): Promise<string[]>;
|
|
86
|
+
/** UTF-8 contents of a repo-relative file, or null when it cannot be read safely; omitted by a client with no local repo. */
|
|
87
|
+
fileContents?(sessionId: string, path: string): Promise<string | null>;
|
|
88
|
+
/** Tracked, repo-relative paths for the git repo containing `cwd`, for the no-session welcome shell. */
|
|
89
|
+
repoFiles?(cwd: string): Promise<string[]>;
|
|
90
|
+
/** UTF-8 contents of a repo-relative file in `cwd`'s repo, or null when unreadable. */
|
|
91
|
+
repoFileContents?(cwd: string, path: string): Promise<string | null>;
|
|
92
|
+
/** Changed files (path plus git status) in the working tree at `cwd`. */
|
|
93
|
+
repoChanges?(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]>;
|
|
81
94
|
/** Move a branch's tip (the current one, or `branch` after switching to it) back to an entry on its path; a summary records the abandoned segment. */
|
|
82
95
|
sessionNavigate(
|
|
83
96
|
id: string,
|
|
@@ -365,6 +378,28 @@ export class DaemonClient implements SessionClient {
|
|
|
365
378
|
sessionSetViewed(id: string, viewedPaths: string[]): Promise<ReviewSession> {
|
|
366
379
|
return this.request("session.setViewed", { id, viewedPaths }, SessionRecordSchema);
|
|
367
380
|
}
|
|
381
|
+
sessionSetTitle(id: string, title: string): Promise<ReviewSession> {
|
|
382
|
+
return this.request("session.setTitle", { id, title }, SessionRecordSchema);
|
|
383
|
+
}
|
|
384
|
+
projectFiles(sessionId: string): Promise<string[]> {
|
|
385
|
+
return this.request("session.projectFiles", { id: sessionId }, v.array(v.string()));
|
|
386
|
+
}
|
|
387
|
+
fileContents(sessionId: string, path: string): Promise<string | null> {
|
|
388
|
+
return this.request("session.fileContents", { id: sessionId, path }, v.nullable(v.string()));
|
|
389
|
+
}
|
|
390
|
+
repoFiles(cwd: string): Promise<string[]> {
|
|
391
|
+
return this.request("repo.files", { cwd }, v.array(v.string()));
|
|
392
|
+
}
|
|
393
|
+
repoFileContents(cwd: string, path: string): Promise<string | null> {
|
|
394
|
+
return this.request("repo.fileContents", { cwd, path }, v.nullable(v.string()));
|
|
395
|
+
}
|
|
396
|
+
repoChanges(cwd: string): Promise<{ path: string; status: DiffFileStatus }[]> {
|
|
397
|
+
return this.request(
|
|
398
|
+
"repo.changes",
|
|
399
|
+
{ cwd },
|
|
400
|
+
v.array(v.object({ path: v.string(), status: v.picklist(["added", "modified", "deleted"]) })),
|
|
401
|
+
);
|
|
402
|
+
}
|
|
368
403
|
/** Re-capture a diff session's working tree; changed=true when the patch moved and an event fired. */
|
|
369
404
|
sessionRefreshDiff(id: string): Promise<{ changed: boolean }> {
|
|
370
405
|
return this.request("session.refreshDiff", { id }, RefreshDiffResultSchema);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** Read-only repo introspection: the tracked file tree and safe, bounded file reads. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { listProjectFiles, readProjectFile } from "./project-files";
|
|
8
|
+
|
|
9
|
+
function git(args: string[], cwd: string): void {
|
|
10
|
+
const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "ignore", stderr: "ignore" });
|
|
11
|
+
|
|
12
|
+
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed in ${cwd}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let repo: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
repo = mkdtempSync(join(tmpdir(), "cueloop-project-files-"));
|
|
19
|
+
git(["init", "-q", "-b", "main"], repo);
|
|
20
|
+
git(["config", "user.email", "t@t"], repo);
|
|
21
|
+
git(["config", "user.name", "t"], repo);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
rmSync(repo, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("listProjectFiles", () => {
|
|
29
|
+
test("returns the tracked files sorted ascending", async () => {
|
|
30
|
+
// Given a repo whose files are committed out of alphabetical order
|
|
31
|
+
writeFileSync(join(repo, "z.ts"), "export const z = 1;\n");
|
|
32
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 1;\n");
|
|
33
|
+
writeFileSync(join(repo, "m.ts"), "export const m = 1;\n");
|
|
34
|
+
git(["add", "."], repo);
|
|
35
|
+
git(["commit", "-qm", "init"], repo);
|
|
36
|
+
|
|
37
|
+
// When the tracked files are listed
|
|
38
|
+
const files = await listProjectFiles(repo);
|
|
39
|
+
|
|
40
|
+
// Then every tracked path comes back in ascending order
|
|
41
|
+
expect(files).toEqual(["a.ts", "m.ts", "z.ts"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("returns [] for a directory that is not a git repo", async () => {
|
|
45
|
+
// Given a plain directory with a file but no git repo
|
|
46
|
+
const plain = mkdtempSync(join(tmpdir(), "cueloop-project-files-plain-"));
|
|
47
|
+
|
|
48
|
+
writeFileSync(join(plain, "a.ts"), "export const a = 1;\n");
|
|
49
|
+
try {
|
|
50
|
+
// When the tracked files are listed
|
|
51
|
+
const files = await listProjectFiles(plain);
|
|
52
|
+
|
|
53
|
+
// Then nothing comes back and no error is thrown
|
|
54
|
+
expect(files).toEqual([]);
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(plain, { recursive: true, force: true });
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("returns [] when there is no repoRoot", async () => {
|
|
61
|
+
expect(await listProjectFiles(undefined)).toEqual([]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("readProjectFile", () => {
|
|
66
|
+
test("reads a repo-relative file as UTF-8", async () => {
|
|
67
|
+
// Given a committed file with known contents
|
|
68
|
+
writeFileSync(join(repo, "a.ts"), "export const a = 1;\n");
|
|
69
|
+
git(["add", "."], repo);
|
|
70
|
+
git(["commit", "-qm", "init"], repo);
|
|
71
|
+
|
|
72
|
+
// When it is read by its repo-relative path
|
|
73
|
+
const contents = await readProjectFile(repo, "a.ts");
|
|
74
|
+
|
|
75
|
+
// Then its exact bytes come back
|
|
76
|
+
expect(contents).toBe("export const a = 1;\n");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("returns null for a path that escapes the repo via ../", async () => {
|
|
80
|
+
// Given a secret file just outside the repo root
|
|
81
|
+
const secret = join(repo, "..", "outside-secret.txt");
|
|
82
|
+
|
|
83
|
+
writeFileSync(secret, "top secret\n");
|
|
84
|
+
try {
|
|
85
|
+
// When a traversal path tries to reach it
|
|
86
|
+
const contents = await readProjectFile(repo, "../outside-secret.txt");
|
|
87
|
+
|
|
88
|
+
// Then the read is refused
|
|
89
|
+
expect(contents).toBeNull();
|
|
90
|
+
} finally {
|
|
91
|
+
rmSync(secret, { force: true });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("returns null for a missing file", async () => {
|
|
96
|
+
expect(await readProjectFile(repo, "nope.ts")).toBeNull();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("returns null for a directory (not a regular file)", async () => {
|
|
100
|
+
// Given a directory inside the repo
|
|
101
|
+
mkdirSync(join(repo, "src"));
|
|
102
|
+
|
|
103
|
+
// When the directory path is read
|
|
104
|
+
const contents = await readProjectFile(repo, "src");
|
|
105
|
+
|
|
106
|
+
// Then it is refused
|
|
107
|
+
expect(contents).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("returns null for a file larger than the cap", async () => {
|
|
111
|
+
// Given a file just over one megabyte
|
|
112
|
+
writeFileSync(join(repo, "big.bin"), "x".repeat(1024 * 1024 + 1));
|
|
113
|
+
|
|
114
|
+
// When it is read
|
|
115
|
+
const contents = await readProjectFile(repo, "big.bin");
|
|
116
|
+
|
|
117
|
+
// Then it is refused for exceeding the size cap
|
|
118
|
+
expect(contents).toBeNull();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("returns null when there is no repoRoot", async () => {
|
|
122
|
+
expect(await readProjectFile(undefined, "a.ts")).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Read-only repo introspection for the Project panel: the tracked file tree and a file's contents. */
|
|
2
|
+
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
|
+
|
|
6
|
+
/** Ceiling on the returned tree; a pathological repo cannot flood the panel or the wire. */
|
|
7
|
+
const MAX_FILES = 5000;
|
|
8
|
+
/** Ceiling on a served file; the panel renders source, not multi-megabyte blobs. */
|
|
9
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
10
|
+
|
|
11
|
+
/** Tracked, repo-relative paths from `git ls-files`, sorted ascending and capped; [] on any failure or non-repo. */
|
|
12
|
+
export async function listProjectFiles(repoRoot: string | undefined): Promise<string[]> {
|
|
13
|
+
if (repoRoot === undefined || repoRoot.length === 0) return [];
|
|
14
|
+
try {
|
|
15
|
+
const gitProcess = Bun.spawn(["git", "-C", repoRoot, "ls-files", "-z"], {
|
|
16
|
+
stdout: "pipe",
|
|
17
|
+
stderr: "ignore",
|
|
18
|
+
});
|
|
19
|
+
const stdout = await new Response(gitProcess.stdout).text();
|
|
20
|
+
|
|
21
|
+
if ((await gitProcess.exited) !== 0) return [];
|
|
22
|
+
const paths = stdout.split("\0").filter((path) => path.length > 0);
|
|
23
|
+
|
|
24
|
+
return paths.toSorted().slice(0, MAX_FILES);
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** True when `candidate` resolves outside `root` (path traversal) or is `root` itself. */
|
|
31
|
+
function escapesRoot(root: string, candidate: string): boolean {
|
|
32
|
+
const rel = relative(root, candidate);
|
|
33
|
+
|
|
34
|
+
return rel.length === 0 || rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** UTF-8 contents of `repoRoot/path`, or null: no repo, traversal escape, not a regular file, oversized, or any error. */
|
|
38
|
+
export async function readProjectFile(
|
|
39
|
+
repoRoot: string | undefined,
|
|
40
|
+
path: string,
|
|
41
|
+
): Promise<string | null> {
|
|
42
|
+
if (repoRoot === undefined || repoRoot.length === 0) return null;
|
|
43
|
+
const root = resolve(repoRoot);
|
|
44
|
+
const target = resolve(root, path);
|
|
45
|
+
|
|
46
|
+
if (escapesRoot(root, target)) return null;
|
|
47
|
+
try {
|
|
48
|
+
const stats = await stat(target);
|
|
49
|
+
|
|
50
|
+
if (!stats.isFile() || stats.size > MAX_FILE_BYTES) return null;
|
|
51
|
+
|
|
52
|
+
return await Bun.file(target).text();
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -380,6 +380,27 @@ export class DaemonServer {
|
|
|
380
380
|
|
|
381
381
|
return this.core.sessionSetViewed(params.id, params.viewedPaths);
|
|
382
382
|
},
|
|
383
|
+
"session.setTitle": (_connection, request) => {
|
|
384
|
+
const params = parseParams("session.setTitle", request.params);
|
|
385
|
+
|
|
386
|
+
return this.core.sessionSetTitle(params.id, params.title);
|
|
387
|
+
},
|
|
388
|
+
"session.projectFiles": (_connection, request) =>
|
|
389
|
+
this.core.projectFiles(parseParams("session.projectFiles", request.params).id),
|
|
390
|
+
"session.fileContents": (_connection, request) => {
|
|
391
|
+
const params = parseParams("session.fileContents", request.params);
|
|
392
|
+
|
|
393
|
+
return this.core.fileContents(params.id, params.path);
|
|
394
|
+
},
|
|
395
|
+
"repo.files": (_connection, request) =>
|
|
396
|
+
this.core.repoFiles(parseParams("repo.files", request.params).cwd),
|
|
397
|
+
"repo.fileContents": (_connection, request) => {
|
|
398
|
+
const params = parseParams("repo.fileContents", request.params);
|
|
399
|
+
|
|
400
|
+
return this.core.repoFileContents(params.cwd, params.path);
|
|
401
|
+
},
|
|
402
|
+
"repo.changes": (_connection, request) =>
|
|
403
|
+
this.core.repoChanges(parseParams("repo.changes", request.params).cwd),
|
|
383
404
|
"session.refreshDiff": (_connection, request) => {
|
|
384
405
|
const params = parseParams("session.refreshDiff", request.params);
|
|
385
406
|
|
package/src/validate.ts
CHANGED
|
@@ -166,6 +166,12 @@ export const Params = {
|
|
|
166
166
|
),
|
|
167
167
|
}),
|
|
168
168
|
"session.setViewed": v.object({ id: SessionId, viewedPaths: v.array(v.string()) }),
|
|
169
|
+
"session.setTitle": v.object({ id: SessionId, title: v.string() }),
|
|
170
|
+
"session.projectFiles": v.object({ id: SessionId }),
|
|
171
|
+
"session.fileContents": v.object({ id: SessionId, path: NonEmpty }),
|
|
172
|
+
"repo.files": v.object({ cwd: NonEmpty }),
|
|
173
|
+
"repo.fileContents": v.object({ cwd: NonEmpty, path: NonEmpty }),
|
|
174
|
+
"repo.changes": v.object({ cwd: NonEmpty }),
|
|
169
175
|
"session.navigate": v.object({
|
|
170
176
|
id: SessionId,
|
|
171
177
|
entryId: NonEmpty,
|
package/src/working-tree.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** Working-tree diff capture for `cueloop diff`, untracked files included. */
|
|
2
2
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import type { DiffFileContents } from "@cueloop/schema";
|
|
4
|
+
import type { DiffFileContents, DiffFileStatus } from "@cueloop/schema";
|
|
5
5
|
|
|
6
6
|
async function git(args: string[], cwd: string): Promise<string | null> {
|
|
7
7
|
const gitProcess = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "ignore" });
|
|
@@ -12,6 +12,42 @@ async function git(args: string[], cwd: string): Promise<string | null> {
|
|
|
12
12
|
return stdout.trim();
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** Map a `git diff --name-status` letter to a diff status; renames/copies read as modified. */
|
|
16
|
+
function statusFromCode(code: string): DiffFileStatus {
|
|
17
|
+
const letter = code.charAt(0);
|
|
18
|
+
if (letter === "D") return "deleted";
|
|
19
|
+
if (letter === "A") return "added";
|
|
20
|
+
|
|
21
|
+
return "modified";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The working tree's changed files as repo-relative path plus status - a fast listing for the changes
|
|
26
|
+
* navigator that never reads file contents (unlike workingTreeDiff, which captures full curation copies).
|
|
27
|
+
* Uses tab-separated name-status so a trimmed leading column can never shift the parse.
|
|
28
|
+
*/
|
|
29
|
+
export async function workingChangeList(
|
|
30
|
+
cwd = process.cwd(),
|
|
31
|
+
): Promise<{ path: string; status: DiffFileStatus }[]> {
|
|
32
|
+
const changes: { path: string; status: DiffFileStatus }[] = [];
|
|
33
|
+
const tracked = (await git(["diff", "--name-status", "HEAD"], cwd)) ?? "";
|
|
34
|
+
|
|
35
|
+
for (const line of tracked.split("\n")) {
|
|
36
|
+
if (line.trim().length === 0) continue;
|
|
37
|
+
const parts = line.split("\t");
|
|
38
|
+
// a rename is "R100<tab>old<tab>new"; the new path (last field) is what the reviewer opens
|
|
39
|
+
changes.push({ path: parts[parts.length - 1]!, status: statusFromCode(parts[0]!) });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const untracked = (await git(["ls-files", "--others", "--exclude-standard"], cwd)) ?? "";
|
|
43
|
+
|
|
44
|
+
for (const path of untracked.split("\n")) {
|
|
45
|
+
if (path.trim().length > 0) changes.push({ path, status: "added" });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return changes;
|
|
49
|
+
}
|
|
50
|
+
|
|
15
51
|
/** The blob at HEAD for a path, or "" when the path is not in HEAD (new file). */
|
|
16
52
|
async function headContents(path: string, cwd: string): Promise<string> {
|
|
17
53
|
const gitProcess = Bun.spawn(["git", "show", `HEAD:${path}`], {
|