Repository Public
1import { create } from 'zustand';2import type { FileContent } from '@cr/shared';3import { api, ApiRequestError, errorMessage, qs } from '@/lib/api';4import { reverseApplyUnifiedDiff } from '@/lib/patch';56export type BufferStatus = 'loading' | 'ready' | 'error' | 'binary' | 'too_large' | 'preview';7export interface Buffer {8 key: string;9 projectId: string;10 path: string;11 kind: 'file' | 'diff';12 status: BufferStatus;13 content: string;14 saved: string;15 sha: string | null;16 size: number | null;17 mtimeMs: number | null;18 error: string | null;19 saving: boolean;20 /** Set when a save hit 409 `conflict`: the file changed on disk since it was opened. */21 conflict: boolean;22 /** Incremented on every conflicting save, so the dialog reopens after being dismissed. */23 conflictSeq: number;24 /** diff tabs: left side; `rawDiff` is shown when the patch could not be reverse-applied. */25 original: string | null;26 rawDiff: string | null;27 diffTitle: string | null;28}29const blank = (projectId: string, path: string, kind: Buffer['kind']): Buffer => ({30 key: `${kind}:${projectId}:${path}`, projectId, path, kind, status: 'loading', content: '', saved: '', sha: null, size: null, mtimeMs: null,31 error: null, saving: false, conflict: false, conflictSeq: 0, original: null, rawDiff: null, diffTitle: null,32});3334const IMAGE = /\.(png|jpe?g|gif|webp|avif|bmp|ico)$/i;35export const isImagePath = (p: string) => IMAGE.test(p);36export const isPdfPath = (p: string) => /\.pdf$/i.test(p);37export const isMarkdownPath = (p: string) => /\.(md|markdown|mdx)$/i.test(p);38export const isJsonPath = (p: string) => /\.json$/i.test(p);3940interface EditorState {41 tabs: Buffer[];42 activeKey: string | null;43 open: (projectId: string, path: string) => Promise<void>;44 openGitDiff: (projectId: string, path: string) => Promise<void>;45 openConflictDiff: (key: string) => Promise<void>;46 close: (key: string) => void;47 activate: (key: string) => void;48 setContent: (key: string, content: string) => void;49 save: (key: string) => Promise<'saved' | 'conflict' | 'error' | 'noop'>;50 /** Conflict resolution: discard local edits and load the on-disk version. */51 reloadTheirs: (key: string) => Promise<void>;52 /** Conflict resolution: write local content over the current on-disk version. */53 overwrite: (key: string) => Promise<'saved' | 'conflict' | 'error' | 'noop'>;54 renamePath: (projectId: string, from: string, to: string) => void;55 removePath: (projectId: string, path: string) => void;56}5758const fetchFile = (projectId: string, path: string) => api.get<FileContent>(`/projects/${projectId}/file${qs({ path })}`);5960export const useEditor = create<EditorState>((set, get) => {61 const patch = (key: string, p: Partial<Buffer>) => set((s) => ({ tabs: s.tabs.map((t) => (t.key === key ? { ...t, ...p } : t)) }));62 const find = (key: string) => get().tabs.find((t) => t.key === key);63 const load = async (key: string, projectId: string, path: string) => {64 try {65 const f = await fetchFile(projectId, path);66 patch(key, { status: 'ready', content: f.content, saved: f.content, sha: f.sha256, size: f.size, mtimeMs: f.mtimeMs, error: null, conflict: false });67 } catch (e) {68 const d = e instanceof ApiRequestError ? (e.details as { size?: number; mtimeMs?: number } | undefined) : undefined;69 const meta = { size: typeof d?.size === 'number' ? d.size : null, mtimeMs: typeof d?.mtimeMs === 'number' ? d.mtimeMs : null };70 if (e instanceof ApiRequestError && e.status === 413) patch(key, { status: 'too_large', ...meta });71 else if (e instanceof ApiRequestError && e.status === 415) patch(key, { status: 'binary', ...meta });72 else patch(key, { status: 'error', error: errorMessage(e) });73 }74 };75 const write = async (key: string, baseSha256: string | null): Promise<'saved' | 'conflict' | 'error' | 'noop'> => {76 const b = find(key);77 if (!b || b.kind !== 'file' || b.status !== 'ready' || b.saving) return 'noop';78 const content = b.content;79 patch(key, { saving: true });80 try {81 const f = await api.put<FileContent>(`/projects/${b.projectId}/file`, { path: b.path, content, ...(baseSha256 ? { baseSha256 } : {}) });82 patch(key, { saving: false, saved: content, sha: f.sha256, size: f.size, mtimeMs: f.mtimeMs, conflict: false, error: null });83 return 'saved';84 } catch (e) {85 if (e instanceof ApiRequestError && e.status === 409 && e.code === 'conflict') {86 patch(key, { saving: false, conflict: true, conflictSeq: b.conflictSeq + 1 });87 return 'conflict';88 }89 patch(key, { saving: false, error: errorMessage(e) });90 return 'error';91 }92 };93 return {94 tabs: [],95 activeKey: null,96 open: async (projectId, path) => {97 const b = blank(projectId, path, 'file');98 if (find(b.key)) return set({ activeKey: b.key });99 if (isImagePath(path) || isPdfPath(path)) return set((s) => ({ tabs: [...s.tabs, { ...b, status: 'preview' }], activeKey: b.key }));100 set((s) => ({ tabs: [...s.tabs, b], activeKey: b.key }));101 await load(b.key, projectId, path);102 },103 openGitDiff: async (projectId, path) => {104 const b = { ...blank(projectId, path, 'diff'), diffTitle: 'HEAD ↔ working tree' };105 set((s) => ({ tabs: [...s.tabs.filter((t) => t.key !== b.key), b], activeKey: b.key }));106 try {107 const res = await api.get<{ diff?: string; truncated?: boolean } | string>(`/projects/${projectId}/git/diff${qs({ path })}`);108 const diff = typeof res === 'string' ? res : (res?.diff ?? '');109 // A truncated diff cannot be reverse-applied: show what the server returned, as text.110 if (typeof res !== 'string' && res?.truncated) return patch(b.key, { status: 'ready', rawDiff: `${diff}\n\n[diff truncated by the server]` });111 let modified = '';112 try {113 modified = (await fetchFile(projectId, path)).content;114 } catch (e) {115 // deleted in the working tree → empty right side; binary/too large → raw diff only116 if (!(e instanceof ApiRequestError && e.status === 404)) return patch(b.key, { status: 'ready', rawDiff: diff || 'No textual diff available for this file.' });117 }118 const original = reverseApplyUnifiedDiff(modified, diff);119 if (original === null) patch(b.key, { status: 'ready', rawDiff: diff });120 else patch(b.key, { status: 'ready', original, content: modified, saved: modified });121 } catch (e) {122 patch(b.key, { status: 'error', error: errorMessage(e) });123 }124 },125 openConflictDiff: async (key) => {126 const src = find(key);127 if (!src) return;128 const b = { ...blank(src.projectId, src.path, 'diff'), key: `conflict:${src.projectId}:${src.path}`, diffTitle: 'On disk (theirs) ↔ your edits' };129 set((s) => ({ tabs: [...s.tabs.filter((t) => t.key !== b.key), b], activeKey: b.key }));130 try {131 const theirs = await fetchFile(src.projectId, src.path);132 patch(b.key, { status: 'ready', original: theirs.content, content: src.content, saved: src.content });133 } catch (e) {134 patch(b.key, { status: 'error', error: errorMessage(e) });135 }136 },137 close: (key) =>138 set((s) => {139 const i = s.tabs.findIndex((t) => t.key === key);140 const tabs = s.tabs.filter((t) => t.key !== key);141 return { tabs, activeKey: s.activeKey === key ? (tabs[Math.min(i, tabs.length - 1)]?.key ?? null) : s.activeKey };142 }),143 activate: (key) => set({ activeKey: key }),144 setContent: (key, content) => patch(key, { content }),145 save: async (key) => {146 const b = find(key);147 if (!b || b.content === b.saved) return 'noop';148 return write(key, b.sha);149 },150 reloadTheirs: async (key) => {151 const b = find(key);152 if (!b) return;153 patch(key, { status: 'loading' });154 await load(key, b.projectId, b.path);155 },156 overwrite: async (key) => {157 const b = find(key);158 if (!b) return 'noop';159 try {160 // Base the write on the version currently on disk: exactly what "View diff" showed.161 const theirs = await fetchFile(b.projectId, b.path);162 return await write(key, theirs.sha256);163 } catch (e) {164 if (e instanceof ApiRequestError && e.status === 404) return write(key, null);165 patch(key, { error: errorMessage(e) });166 return 'error';167 }168 },169 renamePath: (projectId, from, to) =>170 set((s) => {171 const map = (t: Buffer): Buffer => {172 if (t.projectId !== projectId || (t.path !== from && !t.path.startsWith(`${from}/`))) return t;173 const path = to + t.path.slice(from.length);174 return { ...t, path, key: t.key.replace(`${projectId}:${t.path}`, `${projectId}:${path}`) };175 };176 const tabs = s.tabs.map(map);177 const active = s.tabs.findIndex((t) => t.key === s.activeKey);178 return { tabs, activeKey: active >= 0 ? (tabs[active]?.key ?? null) : s.activeKey };179 }),180 removePath: (projectId, path) => {181 for (const t of get().tabs) if (t.projectId === projectId && (t.path === path || t.path.startsWith(`${path}/`))) get().close(t.key);182 },183 };184});185export const isDirty = (b: Buffer): boolean => b.kind === 'file' && b.status === 'ready' && b.content !== b.saved;186