mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
fix(web): don't raise a global error toast when a file diff is unavailable
Clicking a changed file the daemon can't diff (new/untracked/binary/deleted) made loadFileDiff push a session-level 'kimi server api' error toast on a routine click. The DiffView already shows a graceful 'no diff' state when the lines are empty, so demote the failure to a console.warn (captured by the trace export) and let the pane handle it locally.
This commit is contained in:
parent
da18be330c
commit
90b1108701
12 changed files with 424 additions and 17 deletions
|
|
@ -1460,6 +1460,36 @@ const server = http.createServer((req, res) => {
|
|||
return res.end(ok(gs));
|
||||
}
|
||||
|
||||
// ---- fs:diff ----
|
||||
// POST /api/v1/sessions/{id}/fs:diff body: { path }
|
||||
if (seg[0] === 'sessions' && seg[2] === 'fs:diff' && seg.length === 3 && method === 'POST') {
|
||||
const session = sessions.find((s) => s.id === sid);
|
||||
if (!session) return res.end(fail(40401, `session ${sid} does not exist`));
|
||||
const b = json();
|
||||
if (!b.path) return res.end(fail(40001, 'path: required'));
|
||||
// Deterministic fake unified diff so the Changed → diff flow is demoable.
|
||||
const diff = [
|
||||
`diff --git a/${b.path} b/${b.path}`,
|
||||
'index 1111111..2222222 100644',
|
||||
`--- a/${b.path}`,
|
||||
`+++ b/${b.path}`,
|
||||
'@@ -1,7 +1,9 @@',
|
||||
` // ${b.path}`,
|
||||
' ',
|
||||
'-export function oldImpl(input) {',
|
||||
'- return null;',
|
||||
'+export function newImpl(input) {',
|
||||
'+ // handle the empty-state branch the old impl missed',
|
||||
'+ if (!input) return fallback();',
|
||||
'+ return compute(input);',
|
||||
' }',
|
||||
' ',
|
||||
' export const VERSION = 2;',
|
||||
'',
|
||||
].join('\n');
|
||||
return res.end(ok({ path: b.path, diff, truncated: false }));
|
||||
}
|
||||
|
||||
// ---- fs:list ----
|
||||
// POST /api/v1/sessions/{id}/fs:list body: { path, depth?, include_git_status? }
|
||||
if (seg[0] === 'sessions' && seg[2] === 'fs:list' && seg.length === 3 && method === 'POST') {
|
||||
|
|
|
|||
|
|
@ -734,13 +734,11 @@ export class DaemonKimiWebApi implements KimiWebApi {
|
|||
|
||||
async getFileDiff(
|
||||
sessionId: string,
|
||||
path?: string,
|
||||
path: string,
|
||||
): Promise<{ path: string; diff: string }> {
|
||||
const body: Record<string, unknown> = {};
|
||||
if (path !== undefined) body['path'] = path;
|
||||
const data = await this.http.post<WireDiffResult>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/fs:diff`,
|
||||
body,
|
||||
{ path },
|
||||
);
|
||||
return { path: data.path, diff: data.diff };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -493,7 +493,7 @@ export interface KimiWebApi {
|
|||
searchFiles(sessionId: string, input: { query: string; limit?: number }): Promise<{ items: Array<{ path: string; name: string; kind: FsKind; score: number; matchPositions: number[] }>; truncated: boolean }>;
|
||||
grepFiles(sessionId: string, input: { pattern: string; regex?: boolean; caseSensitive?: boolean }): Promise<{ files: Array<{ path: string; matches: Array<{ line: number; col: number; text: string; before: string[]; after: string[] }> }>; filesScanned: number; truncated: boolean; elapsedMs: number }>;
|
||||
getGitStatus(sessionId: string, paths?: string[]): Promise<{ branch: string; ahead: number; behind: number; entries: Record<string, string> }>;
|
||||
getFileDiff(sessionId: string, path?: string): Promise<{ path: string; diff: string }>;
|
||||
getFileDiff(sessionId: string, path: string): Promise<{ path: string; diff: string }>;
|
||||
getFileDownloadUrl(sessionId: string, path: string): string;
|
||||
openFile(sessionId: string, input: { path: string; line?: number }): Promise<{ opened: true }>;
|
||||
revealFile(sessionId: string, input: { path: string }): Promise<{ revealed: true }>;
|
||||
|
|
|
|||
|
|
@ -185,6 +185,11 @@ async function handleFileSelect(entry: FsEntry): Promise<void> {
|
|||
// ---------------------------------------------------------------------------
|
||||
const changedView = ref<'changed' | 'all'>('changed');
|
||||
|
||||
// Non-git workspace (gitInfo is null): there is no Changed view to offer —
|
||||
// the Changed|All toggle is hidden and the full file tree shows directly.
|
||||
const hasGit = computed(() => (props.gitInfo ?? null) !== null);
|
||||
const filesView = computed<'changed' | 'all'>(() => (hasGit.value ? changedView.value : 'all'));
|
||||
|
||||
// The "Changed" navigator can show a flat list or a directory tree (persisted).
|
||||
const CHANGED_LAYOUT_KEY = 'kimi-web.changed-layout';
|
||||
function loadChangedLayout(): 'list' | 'tree' {
|
||||
|
|
@ -680,7 +685,7 @@ onUnmounted(() => {
|
|||
half is visible; the divider only exists on desktop). -->
|
||||
<template v-else-if="active === 'files'">
|
||||
<div v-show="!mobile || !filesShowPreview" class="files-nav">
|
||||
<div class="nav-seg">
|
||||
<div v-if="hasGit" class="nav-seg">
|
||||
<div class="seg-group" role="group" :aria-label="t('fileTree.segLabel')">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -702,7 +707,7 @@ onUnmounted(() => {
|
|||
</div>
|
||||
</div>
|
||||
<!-- list/tree layout toggle for the Changed view -->
|
||||
<div v-if="changedView === 'changed'" class="nav-tools">
|
||||
<div v-if="filesView === 'changed'" class="nav-tools">
|
||||
<button
|
||||
type="button"
|
||||
class="layout-toggle"
|
||||
|
|
@ -715,12 +720,12 @@ onUnmounted(() => {
|
|||
</button>
|
||||
</div>
|
||||
<div class="files-nav-body">
|
||||
<template v-if="changedView === 'changed'">
|
||||
<template v-if="filesView === 'changed'">
|
||||
<DiffView
|
||||
v-if="changedLayout === 'list'"
|
||||
mode="list"
|
||||
:changes="changes ?? []"
|
||||
:git-info="null"
|
||||
:git-info="gitInfo ?? null"
|
||||
@open="pickChanged"
|
||||
/>
|
||||
<ChangedTree v-else :changes="changes ?? []" @open="pickChanged" />
|
||||
|
|
@ -758,7 +763,7 @@ onUnmounted(() => {
|
|||
:loading="previewLoading"
|
||||
/>
|
||||
<div v-else class="files-empty">
|
||||
{{ changedView === 'changed' ? t('fileTree.selectChanged') : t('fileTree.selectFile') }}
|
||||
{{ filesView === 'changed' ? t('fileTree.selectChanged') : t('fileTree.selectFile') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,7 @@ const { t } = useI18n();
|
|||
|
||||
const tabs: { key: PaneKey; labelKey: string }[] = [
|
||||
{ key: 'chat', labelKey: 'sidebar.tabChat' },
|
||||
// TODO: temporarily hide the files tab until the feature is ready
|
||||
// { key: 'files', labelKey: 'sidebar.tabFiles' },
|
||||
{ key: 'files', labelKey: 'sidebar.tabFiles' },
|
||||
{ key: 'tasks', labelKey: 'sidebar.tabTasks' },
|
||||
{ key: 'todo', labelKey: 'sidebar.tabTodo' },
|
||||
];
|
||||
|
|
@ -28,8 +27,7 @@ const tabs: { key: PaneKey; labelKey: string }[] = [
|
|||
@click="emit('select', tab.key)"
|
||||
>
|
||||
{{ t(tab.labelKey) }}
|
||||
<!-- TODO: restore when files tab is re-enabled -->
|
||||
<!-- <span v-if="tab.key === 'files' && (changesCount ?? 0) > 0" class="d"></span> -->
|
||||
<span v-if="tab.key === 'files' && (changesCount ?? 0) > 0" class="d"></span>
|
||||
<span v-if="tab.key === 'tasks' && runningTasks > 0" class="cnt">{{ runningTasks }}</span>
|
||||
<span v-if="tab.key === 'todo' && (todos?.length ?? 0) > 0" class="cnt">{{ (todos?.filter((t) => t.status === 'done').length ?? 0) }}/{{ todos!.length }}</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1504,8 +1504,13 @@ async function loadFileDiff(path: string): Promise<void> {
|
|||
if (selectedDiffPath.value !== path) return;
|
||||
fileDiffLines.value = parseDiff(result.diff);
|
||||
} catch (err) {
|
||||
// A single file's diff failing (a new/untracked/binary/deleted file the
|
||||
// daemon can't diff) is LOCAL to this pane, not a session-level fault — the
|
||||
// DiffView already shows a graceful "no diff" state when the lines are
|
||||
// empty. Surfacing it as a global "kimi server api" error toast on a routine
|
||||
// file click is disproportionate, so log it for the trace export instead.
|
||||
if (selectedDiffPath.value === path) fileDiffLines.value = [];
|
||||
pushOperationFailure('loadFileDiff', err, { sessionId: sid });
|
||||
console.warn('[loadFileDiff] diff unavailable for', path, err);
|
||||
} finally {
|
||||
if (selectedDiffPath.value === path) fileDiffLoading.value = false;
|
||||
}
|
||||
|
|
|
|||
84
apps/kimi-web/test/files-tab-no-git.test.ts
Normal file
84
apps/kimi-web/test/files-tab-no-git.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// apps/kimi-web/test/files-tab-no-git.test.ts
|
||||
//
|
||||
// Files tab without git: a workspace that is not a git repository (gitInfo is
|
||||
// null) has no "Changed" view to offer — the Changed|All toggle must not
|
||||
// render and the full file tree shows directly. With git info present the
|
||||
// toggle renders and defaults to the Changed view.
|
||||
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
import ConversationPane from '../src/components/ConversationPane.vue';
|
||||
import type { ChatTurn, ConversationStatus } from '../src/types';
|
||||
|
||||
const status: ConversationStatus = {
|
||||
model: 'kimi-test',
|
||||
modelId: 'kimi-test',
|
||||
ctxUsed: 0,
|
||||
ctxMax: 0,
|
||||
permission: 'manual',
|
||||
branch: 'main',
|
||||
cwd: '/repo',
|
||||
isGitRepo: true,
|
||||
};
|
||||
|
||||
const turns: ChatTurn[] = [{ id: 't1', role: 'user', no: 1, text: 'hi' }];
|
||||
|
||||
// Heavy children are irrelevant here — the toggle and navigator choice are
|
||||
// ConversationPane's own template logic.
|
||||
const stubs = {
|
||||
TabBar: true,
|
||||
ChatPane: true,
|
||||
Composer: true,
|
||||
TasksPane: true,
|
||||
TasksCard: true,
|
||||
TodoCard: true,
|
||||
QuestionCard: true,
|
||||
FileTree: true,
|
||||
DiffView: true,
|
||||
ChangedTree: true,
|
||||
};
|
||||
|
||||
function mountFilesTab(gitInfo: { branch: string; ahead: number; behind: number } | null, changes: { path: string; status: string }[]) {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
});
|
||||
const wrapper = mount(ConversationPane, {
|
||||
props: { turns, tasks: [], status, gitInfo, changes },
|
||||
global: { plugins: [i18n], stubs },
|
||||
});
|
||||
(wrapper.vm as unknown as { switchTab(tab: string): void }).switchTab('files');
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('files tab in a non-git workspace', () => {
|
||||
it('hides the Changed|All toggle and shows the full file tree directly', async () => {
|
||||
const wrapper = mountFilesTab(null, []);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.find('.seg-btn').exists()).toBe(false);
|
||||
expect(wrapper.find('file-tree-stub').exists()).toBe(true);
|
||||
expect(wrapper.find('changed-tree-stub').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('with git info: shows the toggle and defaults to the Changed view', async () => {
|
||||
const wrapper = mountFilesTab({ branch: 'main', ahead: 0, behind: 0 }, [
|
||||
{ path: 'a.ts', status: 'modified' },
|
||||
]);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.findAll('.seg-btn').length).toBe(2);
|
||||
expect(wrapper.find('changed-tree-stub').exists() || wrapper.find('diff-view-stub').exists()).toBe(true);
|
||||
expect(wrapper.find('file-tree-stub').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -39,6 +39,11 @@
|
|||
* Response data: FsGitStatusResponse { branch, ahead, behind, entries }
|
||||
* Errors: 40401, 40908 (not a git repo), 41304
|
||||
*
|
||||
* POST /v1/sessions/{sid}/fs:diff
|
||||
* Body: FsDiffRequest { path }
|
||||
* Response data: FsDiffResponse { path, diff, truncated }
|
||||
* Errors: 40401, 40409, 40908 (not a git repo), 41304
|
||||
*
|
||||
* GET /v1/sessions/{sid}/fs/{path}:download
|
||||
* Response: binary stream or envelope (40401 / 40409 / 41304)
|
||||
*
|
||||
|
|
@ -228,6 +233,18 @@ export const fsGitStatusResponseSchema = z.object({
|
|||
});
|
||||
export type FsGitStatusResponse = z.infer<typeof fsGitStatusResponseSchema>;
|
||||
|
||||
export const fsDiffRequestSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
});
|
||||
export type FsDiffRequest = z.infer<typeof fsDiffRequestSchema>;
|
||||
|
||||
export const fsDiffResponseSchema = z.object({
|
||||
path: z.string(),
|
||||
diff: z.string(),
|
||||
truncated: z.boolean(),
|
||||
});
|
||||
export type FsDiffResponse = z.infer<typeof fsDiffResponseSchema>;
|
||||
|
||||
export const fsDownloadParamsSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
range: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { createReadStream } from 'node:fs';
|
|||
|
||||
import {
|
||||
ErrorCode,
|
||||
fsDiffRequestSchema,
|
||||
fsGitStatusRequestSchema,
|
||||
fsGrepRequestSchema,
|
||||
fsListManyRequestSchema,
|
||||
|
|
@ -14,6 +15,7 @@ import {
|
|||
fsSearchRequestSchema,
|
||||
fsStatManyRequestSchema,
|
||||
fsStatRequestSchema,
|
||||
type FsDiffRequest,
|
||||
type FsGitStatusRequest,
|
||||
type FsGrepRequest,
|
||||
type FsListManyRequest,
|
||||
|
|
@ -101,6 +103,7 @@ const FS_ACTIONS = [
|
|||
'search',
|
||||
'grep',
|
||||
'git_status',
|
||||
'diff',
|
||||
'open',
|
||||
'reveal',
|
||||
] as const;
|
||||
|
|
@ -130,7 +133,7 @@ export function registerFsRoutes(
|
|||
[ErrorCode.FS_GIT_UNAVAILABLE]: {},
|
||||
},
|
||||
description:
|
||||
'Filesystem action dispatcher. Supported actions: list, read, list_many, stat, stat_many, search, grep, git_status, open, reveal.',
|
||||
'Filesystem action dispatcher. Supported actions: list, read, list_many, stat, stat_many, search, grep, git_status, diff, open, reveal.',
|
||||
tags: ['fs'],
|
||||
operationId: 'fsAction',
|
||||
},
|
||||
|
|
@ -188,6 +191,9 @@ export function registerFsRoutes(
|
|||
case 'git_status':
|
||||
await handleGitStatus(ix, session_id, req, reply);
|
||||
return;
|
||||
case 'diff':
|
||||
await handleDiff(ix, session_id, req, reply);
|
||||
return;
|
||||
case 'open':
|
||||
await handleOpen(ix, session_id, req, reply);
|
||||
return;
|
||||
|
|
@ -463,6 +469,24 @@ async function handleGitStatus(
|
|||
reply.send(okEnvelope(data, req.id));
|
||||
}
|
||||
|
||||
async function handleDiff(
|
||||
ix: IInstantiationService,
|
||||
sessionId: string,
|
||||
req: { id: string; body: unknown },
|
||||
reply: { send(payload: unknown): unknown },
|
||||
): Promise<void> {
|
||||
const parsed = fsDiffRequestSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
reply.send(buildValidationEnvelope(parsed.error.issues, req.id));
|
||||
return;
|
||||
}
|
||||
const body: FsDiffRequest = parsed.data;
|
||||
const data = await ix.invokeFunction((a) =>
|
||||
a.get(IFsGitService).diff(sessionId, body),
|
||||
);
|
||||
reply.send(okEnvelope(data, req.id));
|
||||
}
|
||||
|
||||
async function handleOpen(
|
||||
ix: IInstantiationService,
|
||||
sessionId: string,
|
||||
|
|
|
|||
|
|
@ -247,6 +247,158 @@ describe('POST /api/v1/sessions/{sid}/fs:git_status (W11.2)', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/sessions/{sid}/fs:diff', () => {
|
||||
it('modified file: unified diff with -old/+new lines', async () => {
|
||||
initRepo();
|
||||
writeFileSync(join(workspace, 'seed.txt'), 'changed\n');
|
||||
|
||||
const r = await bootDaemon();
|
||||
const sid = await createSession(r);
|
||||
const res = await appOf(r).inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/sessions/${sid}/fs:diff`,
|
||||
payload: { path: 'seed.txt' },
|
||||
});
|
||||
const env = envelopeOf<{ path: string; diff: string; truncated: boolean }>(res.json());
|
||||
expect(env.code).toBe(0);
|
||||
expect(env.data!.path).toBe('seed.txt');
|
||||
expect(env.data!.diff).toContain('-seed');
|
||||
expect(env.data!.diff).toContain('+changed');
|
||||
expect(env.data!.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('untracked file: all-added diff against /dev/null', async () => {
|
||||
initRepo();
|
||||
writeFileSync(join(workspace, 'new.txt'), 'brand new\n');
|
||||
|
||||
const r = await bootDaemon();
|
||||
const sid = await createSession(r);
|
||||
const res = await appOf(r).inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/sessions/${sid}/fs:diff`,
|
||||
payload: { path: 'new.txt' },
|
||||
});
|
||||
const env = envelopeOf<{ path: string; diff: string }>(res.json());
|
||||
expect(env.code).toBe(0);
|
||||
expect(env.data!.diff).toContain('+brand new');
|
||||
expect(env.data!.diff).not.toContain('-brand new');
|
||||
});
|
||||
|
||||
it('deleted file: all-removed diff', async () => {
|
||||
initRepo();
|
||||
rmSync(join(workspace, 'seed.txt'));
|
||||
|
||||
const r = await bootDaemon();
|
||||
const sid = await createSession(r);
|
||||
const res = await appOf(r).inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/sessions/${sid}/fs:diff`,
|
||||
payload: { path: 'seed.txt' },
|
||||
});
|
||||
const env = envelopeOf<{ diff: string }>(res.json());
|
||||
expect(env.code).toBe(0);
|
||||
expect(env.data!.diff).toContain('-seed');
|
||||
});
|
||||
|
||||
it('clean tracked file: empty diff', async () => {
|
||||
initRepo();
|
||||
|
||||
const r = await bootDaemon();
|
||||
const sid = await createSession(r);
|
||||
const res = await appOf(r).inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/sessions/${sid}/fs:diff`,
|
||||
payload: { path: 'seed.txt' },
|
||||
});
|
||||
const env = envelopeOf<{ diff: string }>(res.json());
|
||||
expect(env.code).toBe(0);
|
||||
expect(env.data!.diff).toBe('');
|
||||
});
|
||||
|
||||
it('repo without commits: untracked file still diffs all-added', async () => {
|
||||
git(['init', '-b', 'main']);
|
||||
writeFileSync(join(workspace, 'first.txt'), 'first\n');
|
||||
|
||||
const r = await bootDaemon();
|
||||
const sid = await createSession(r);
|
||||
const res = await appOf(r).inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/sessions/${sid}/fs:diff`,
|
||||
payload: { path: 'first.txt' },
|
||||
});
|
||||
const env = envelopeOf<{ diff: string }>(res.json());
|
||||
expect(env.code).toBe(0);
|
||||
expect(env.data!.diff).toContain('+first');
|
||||
});
|
||||
|
||||
it('nonexistent path → 40409', async () => {
|
||||
initRepo();
|
||||
|
||||
const r = await bootDaemon();
|
||||
const sid = await createSession(r);
|
||||
const res = await appOf(r).inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/sessions/${sid}/fs:diff`,
|
||||
payload: { path: 'no-such-file.txt' },
|
||||
});
|
||||
const env = envelopeOf<null>(res.json());
|
||||
expect(env.code).toBe(40409);
|
||||
});
|
||||
|
||||
it('non-git workspace → 40908', async () => {
|
||||
writeFileSync(join(workspace, 'plain.txt'), 'plain\n');
|
||||
|
||||
const r = await bootDaemon();
|
||||
const sid = await createSession(r);
|
||||
const res = await appOf(r).inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/sessions/${sid}/fs:diff`,
|
||||
payload: { path: 'plain.txt' },
|
||||
});
|
||||
const env = envelopeOf<null>(res.json());
|
||||
expect(env.code).toBe(40908);
|
||||
});
|
||||
|
||||
it('path escaping cwd → 41304', async () => {
|
||||
initRepo();
|
||||
|
||||
const r = await bootDaemon();
|
||||
const sid = await createSession(r);
|
||||
const res = await appOf(r).inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/sessions/${sid}/fs:diff`,
|
||||
payload: { path: '../outside.txt' },
|
||||
});
|
||||
const env = envelopeOf<null>(res.json());
|
||||
expect(env.code).toBe(41304);
|
||||
});
|
||||
|
||||
it('missing path → 40001 validation', async () => {
|
||||
initRepo();
|
||||
|
||||
const r = await bootDaemon();
|
||||
const sid = await createSession(r);
|
||||
const res = await appOf(r).inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/sessions/${sid}/fs:diff`,
|
||||
payload: {},
|
||||
});
|
||||
const env = envelopeOf<null>(res.json());
|
||||
expect(env.code).toBe(40001);
|
||||
});
|
||||
|
||||
it('40401 unknown session', async () => {
|
||||
const r = await bootDaemon();
|
||||
const res = await appOf(r).inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/sessions/sess_does_not_exist/fs:diff',
|
||||
payload: { path: 'seed.txt' },
|
||||
});
|
||||
const env = envelopeOf<null>(res.json());
|
||||
expect(env.code).toBe(40401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePorcelain (W11.2)', () => {
|
||||
it('parses a clean tree', () => {
|
||||
const out = parsePorcelain('## main\n', undefined);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import path from 'node:path';
|
|||
|
||||
import { createDecorator, type IDisposable } from '@moonshot-ai/agent-core';
|
||||
import type {
|
||||
FsDiffRequest,
|
||||
FsDiffResponse,
|
||||
FsGitStatus,
|
||||
FsGitStatusRequest,
|
||||
FsGitStatusResponse,
|
||||
|
|
@ -26,6 +28,8 @@ export interface IFsGitService extends IDisposable {
|
|||
sessionId: string,
|
||||
req: FsGitStatusRequest,
|
||||
): Promise<FsGitStatusResponse>;
|
||||
|
||||
diff(sessionId: string, req: FsDiffRequest): Promise<FsDiffResponse>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
|
|
|
|||
|
|
@ -4,12 +4,22 @@ import { spawn } from 'node:child_process';
|
|||
import { promises as fs } from 'node:fs';
|
||||
|
||||
import { Disposable, InstantiationType, registerSingleton } from '@moonshot-ai/agent-core';
|
||||
import type { FsGitStatusRequest, FsGitStatusResponse } from '@moonshot-ai/protocol';
|
||||
import type {
|
||||
FsDiffRequest,
|
||||
FsDiffResponse,
|
||||
FsGitStatusRequest,
|
||||
FsGitStatusResponse,
|
||||
} from '@moonshot-ai/protocol';
|
||||
import { ISessionService } from '../session/session';
|
||||
|
||||
import { FsPathNotFoundError } from './fs';
|
||||
import { IFsGitService, FsGitUnavailableError, parsePorcelain } from './fsGit';
|
||||
import { resolveSafePath } from './fsPathSafety';
|
||||
|
||||
/** Cap a single file's unified diff (a runaway generated file should not blow
|
||||
up the envelope); the response carries `truncated` so the UI can say so. */
|
||||
const DIFF_MAX_BYTES = 1_048_576;
|
||||
|
||||
export class FsGitService extends Disposable implements IFsGitService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
|
|
@ -57,6 +67,86 @@ export class FsGitService extends Disposable implements IFsGitService {
|
|||
|
||||
return parsePorcelain(porcRes.stdout, filterSet);
|
||||
}
|
||||
|
||||
async diff(sessionId: string, req: FsDiffRequest): Promise<FsDiffResponse> {
|
||||
const session = await this.sessions.get(sessionId);
|
||||
const cwd = session.metadata.cwd;
|
||||
const realCwd = await fs.realpath(cwd);
|
||||
const safe = await resolveSafePath(realCwd, req.path);
|
||||
const rel = safe.relative;
|
||||
|
||||
const insideRes = await runCommand('git', ['rev-parse', '--is-inside-work-tree'], realCwd);
|
||||
if (insideRes.exitCode !== 0 || insideRes.stdout.trim() !== 'true') {
|
||||
throw new FsGitUnavailableError(
|
||||
realCwd,
|
||||
insideRes.stderr.trim() || `git rev-parse exit ${insideRes.exitCode}`,
|
||||
);
|
||||
}
|
||||
|
||||
const statusRes = await runCommand(
|
||||
'git',
|
||||
['status', '--porcelain=v1', '--', rel],
|
||||
realCwd,
|
||||
);
|
||||
if (statusRes.exitCode !== 0) {
|
||||
throw new FsGitUnavailableError(
|
||||
realCwd,
|
||||
statusRes.stderr.trim() || `git status exit ${statusRes.exitCode}`,
|
||||
);
|
||||
}
|
||||
const untracked = statusRes.stdout.startsWith('??');
|
||||
|
||||
// A repo with no commits yet has no HEAD to diff against — every changed
|
||||
// file is all-new there, same as the untracked case.
|
||||
const headRes = await runCommand('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], realCwd);
|
||||
const hasHead = headRes.exitCode === 0;
|
||||
|
||||
// An untracked file has no HEAD side; diff it against /dev/null so the UI
|
||||
// gets an all-added hunk. `git diff --no-index` exits 1 when files differ.
|
||||
let diffRes: RunResult;
|
||||
if (untracked || !hasHead) {
|
||||
diffRes = await runCommand(
|
||||
'git',
|
||||
['diff', '--no-color', '--no-index', '--', '/dev/null', rel],
|
||||
realCwd,
|
||||
);
|
||||
if (diffRes.exitCode !== 0 && diffRes.exitCode !== 1) {
|
||||
throw new FsGitUnavailableError(
|
||||
realCwd,
|
||||
diffRes.stderr.trim() || `git diff exit ${diffRes.exitCode}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
diffRes = await runCommand(
|
||||
'git',
|
||||
['diff', '--no-color', 'HEAD', '--', rel],
|
||||
realCwd,
|
||||
);
|
||||
if (diffRes.exitCode !== 0) {
|
||||
throw new FsGitUnavailableError(
|
||||
realCwd,
|
||||
diffRes.stderr.trim() || `git diff exit ${diffRes.exitCode}`,
|
||||
);
|
||||
}
|
||||
if (diffRes.stdout.length === 0 && statusRes.stdout.length === 0) {
|
||||
// Not changed at all — distinguish "clean file" (empty diff is fine)
|
||||
// from a path that doesn't exist anywhere.
|
||||
const exists = await fs
|
||||
.stat(safe.absolute)
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
if (!exists) throw new FsPathNotFoundError(req.path);
|
||||
}
|
||||
}
|
||||
|
||||
const full = diffRes.stdout;
|
||||
const truncated = full.length > DIFF_MAX_BYTES;
|
||||
return {
|
||||
path: rel,
|
||||
diff: truncated ? full.slice(0, DIFF_MAX_BYTES) : full,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface RunResult {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue