SPB Git

Repository Public

editor-store.ts 9.1 KB 186 lines typescript

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
Files main
Menu

Theme