storebound/apps/desktop/src/ui/Editor.tsx

299 lines
20 KiB
TypeScript

// The building blocks of the window's editors (console parity, docs/console-parity.md):
// - useEditable: the values the files hold (project.editable = `storebound edit --show --json`, offline)
// - fields (text, multi-line, number, yes / no, choice, list) that keep unsaved edits in lib/edits.ts, with the
// store limit as a live counter, the checks of `check` / `scan` as the owner types, and "was: …" with a reset
// - EditBar: unsaved count, undo / redo, discard, and "Review and save": the dry run of project.edit in the window
// (old -> new, the diff per file, the checks, git), then Save (native dialog, then the files are written)
// - ApplyPanel: a store write's dry run in the window (old -> new per store and language), then Apply (native
// dialog, the real run)
// - GitNote: the edited files are uncommitted in git (storebound never commits)
import { useCallback, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
import { api, type EditableResult, type EditResult, type Project, type ToolResult } from '../api';
import { t } from '../i18n';
import { type EditArea, type EditOp, edits, type FieldOp, keyOf } from '../lib/edits';
import { failureText } from '../lib/results';
import { useRead, useWrite } from '../store';
import { Icon, type IconName } from './icons';
import { Chip, Notice } from './kit';
import { emailIssue, urlIssue } from '../../../../cli/lib/listing-rules';
import { WriteButton } from './WriteButton';
/** Characters as the stores count them (code points). */
export const chars = (s: string): number => [...s].length;
export function useEditable(project: Project) {
return useRead<EditableResult>(project.id, 'project.editable', { network: false });
}
/** Re-renders on every change of the unsaved edits. */
export function useEdits(): typeof edits {
useSyncExternalStore(edits.subscribe, edits.snapshot);
return edits;
}
export interface FieldIssueView { level: 'error' | 'warn' | 'info'; msg: string }
/** The current value of a field: the unsaved edit, else the saved one. */
export function useValue<T>(project: Project, op: EditOp, saved: T): { value: T; dirty: boolean } {
const e = useEdits();
const p = e.get(project.id, keyOf(op));
if (!p) return { value: saved, dirty: false };
const v = p.op.kind === 'config' ? (p.op.remove ? undefined : p.op.value) : p.op.value;
return { value: (v ?? (typeof saved === 'string' ? '' : v)) as T, dirty: true };
}
function Counter({ n, limit }: { n: number; limit: number }) {
const tone = n > limit ? 'bad' : n > limit * 0.95 ? 'warn' : '';
return <span className={`ed-count num ${tone}`} aria-label={t('ed.countAria', { n, limit })}>{n} / {limit}</span>;
}
function Issues({ issues }: { issues: FieldIssueView[] }) {
if (!issues.length) return null;
return (
<ul className="ed-issues">
{issues.map((i, k) => <li key={k} className={i.level}><Icon name={i.level === 'error' ? 'alertCircle' : i.level === 'warn' ? 'alert' : 'info'} className="ic13" />{i.msg}</li>)}
</ul>
);
}
interface FieldBase { project: Project; area: EditArea; label: string; hint?: ReactNode; issues?: FieldIssueView[]; id?: string; disabled?: boolean }
/** A text field: one line or several, a live character counter against the store limit. */
export function TextField({ project, area, op, saved, label, limit, multiline, rows = 3, placeholder, hint, issues = [], mono, id, disabled, kind }: FieldBase & {
/** The edit without its value: { kind: 'listing', store, lang, field } or { kind: 'config', path } or contact. */
op: FieldOp;
saved: string; limit?: number; multiline?: boolean; rows?: number; placeholder?: string; mono?: boolean; kind?: 'url' | 'email' | 'text';
}) {
const e = useEdits();
const make = (v: string): EditOp => (op.kind === 'config' ? (v === '' ? { kind: 'config', path: op.path, remove: true } : { kind: 'config', path: op.path, value: v }) : { ...op, value: v } as EditOp);
const { value, dirty } = useValue<string>(project, make(saved), saved);
const fid = id ?? `ed-${keyOf(make('')).replace(/[^A-Za-z0-9_-]/g, '-')}`;
const n = chars(value ?? '');
const set = (v: string) => e.set(project.id, make(v), area, saved);
const common = { id: fid, className: `input${mono ? ' mono' : ''}`, value: value ?? '', placeholder, disabled, 'aria-invalid': issues.some(i => i.level === 'error') || undefined,
onChange: (ev: { target: { value: string } }) => set(ev.target.value), 'data-edit': keyOf(make('')) };
return (
<div className={`ed-field${dirty ? ' dirty' : ''}`}>
<div className="ed-head">
<label htmlFor={fid}>{label}{dirty && <span className="ed-dot" title={t('ed.unsavedField')} />}</label>
{limit !== undefined && <Counter n={n} limit={limit} />}
</div>
{multiline ? <textarea rows={rows} {...common} /> : <input type={kind === 'email' ? 'email' : kind === 'url' ? 'url' : 'text'} spellCheck={kind ? false : undefined} {...common} />}
{dirty && (
<div className="ed-was small muted">
<span className="ed-was-text">{t('ed.was')} {saved ? `“${saved.length > 90 ? `${saved.slice(0, 87)}…` : saved}”` : t('ed.empty')}</span>
<button className="btn btn-xs btn-ghost" onClick={() => set(saved)}><Icon name="rotate" />{t('ed.reset')}</button>
</div>
)}
{hint && <div className="ed-hint small muted">{hint}</div>}
<Issues issues={[...(kind === 'url' && urlIssue(value ?? '') ? [{ level: 'error' as const, msg: urlIssue(value ?? '')! }] : []),
...(kind === 'email' && emailIssue(value ?? '') ? [{ level: 'error' as const, msg: emailIssue(value ?? '')! }] : []), ...issues]} />
</div>
);
}
/** A config value from a fixed list (an empty choice removes the key). */
export function ChoiceField({ project, area, path, saved, label, options, hint, issues = [], allowEmpty = true }: FieldBase & {
path: string; saved: string | boolean | null | undefined; options: [string, string][]; allowEmpty?: boolean;
}) {
const e = useEdits();
const toValue = (s: string): unknown => (s === 'true' ? true : s === 'false' ? false : s);
const savedS = saved === null || saved === undefined ? '' : String(saved);
const make = (s: string): EditOp => (s === '' ? { kind: 'config', path, remove: true } : { kind: 'config', path, value: toValue(s) });
const { value, dirty } = useValue<unknown>(project, make(savedS), saved);
const cur = value === null || value === undefined ? '' : String(value);
const fid = `ed-${path.replace(/[^A-Za-z0-9_-]/g, '-')}`;
return (
<div className={`ed-field${dirty ? ' dirty' : ''}`}>
<div className="ed-head"><label htmlFor={fid}>{label}{dirty && <span className="ed-dot" title={t('ed.unsavedField')} />}</label></div>
<select id={fid} className="input" value={cur} data-edit={`config:${path}`} onChange={ev => e.set(project.id, make(ev.target.value), area, saved === undefined ? null : saved)}>
{allowEmpty && <option value="">{t('ed.notSet')}</option>}
{options.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
{hint && <div className="ed-hint small muted">{hint}</div>}
<Issues issues={issues} />
</div>
);
}
/** A number in the config (prices in USD, levels). */
export function NumberField({ project, area, path, saved, label, hint, issues = [], min, step = 'any' }: FieldBase & { path: string; saved: number | null | undefined; min?: number; step?: string }) {
const e = useEdits();
const make = (v: number | null): EditOp => (v === null ? { kind: 'config', path, remove: true } : { kind: 'config', path, value: v });
const { value, dirty } = useValue<number | null | undefined>(project, make(saved ?? null), saved);
const [text, setText] = useState(value === null || value === undefined ? '' : String(value));
useEffect(() => { setText(value === null || value === undefined ? '' : String(value)); }, [value]);
const fid = `ed-${path.replace(/[^A-Za-z0-9_-]/g, '-')}`;
const bad = text !== '' && !Number.isFinite(Number(text));
return (
<div className={`ed-field${dirty ? ' dirty' : ''}`}>
<div className="ed-head"><label htmlFor={fid}>{label}{dirty && <span className="ed-dot" title={t('ed.unsavedField')} />}</label></div>
<input id={fid} className="input num" inputMode="decimal" value={text} data-edit={`config:${path}`} aria-invalid={bad || undefined}
onChange={ev => { setText(ev.target.value); const n = Number(ev.target.value); if (ev.target.value === '') e.set(project.id, make(null), area, saved ?? null); else if (Number.isFinite(n)) e.set(project.id, make(n), area, saved ?? null); }}
min={min} step={step} />
{dirty && <div className="ed-was small muted"><span className="ed-was-text">{t('ed.was')} {saved ?? t('ed.empty')}</span><button className="btn btn-xs btn-ghost" onClick={() => e.set(project.id, make(saved ?? null), area, saved ?? null)}><Icon name="rotate" />{t('ed.reset')}</button></div>}
{hint && <div className="ed-hint small muted">{hint}</div>}
<Issues issues={[...(bad ? [{ level: 'error' as const, msg: t('ed.notNumber') }] : []), ...issues]} />
</div>
);
}
/** A list of short values (countries, age groups, keywords of a config list) as comma-separated text. */
export function ListField({ project, area, path, saved, label, hint, issues = [], upper }: FieldBase & { path: string; saved: string[] | null | undefined; upper?: boolean }) {
const e = useEdits();
const make = (v: string[] | null): EditOp => (v === null || !v.length ? { kind: 'config', path, remove: true } : { kind: 'config', path, value: v });
const { value, dirty } = useValue<string[] | null | undefined>(project, make(saved ?? null), saved);
const [text, setText] = useState((value ?? []).join(', '));
useEffect(() => { setText((value ?? []).join(', ')); }, [JSON.stringify(value)]); // eslint-disable-line react-hooks/exhaustive-deps
const fid = `ed-${path.replace(/[^A-Za-z0-9_-]/g, '-')}`;
return (
<div className={`ed-field${dirty ? ' dirty' : ''}`}>
<div className="ed-head"><label htmlFor={fid}>{label}{dirty && <span className="ed-dot" title={t('ed.unsavedField')} />}</label></div>
<input id={fid} className="input mono" value={text} data-edit={`config:${path}`}
onChange={ev => { setText(ev.target.value); const list = ev.target.value.split(',').map(s => (upper ? s.trim().toUpperCase() : s.trim())).filter(Boolean); e.set(project.id, make(list.length ? list : null), area, saved ?? null); }} />
{hint && <div className="ed-hint small muted">{hint}</div>}
<Issues issues={issues} />
</div>
);
}
/** A block of the config as JSON (offers, promoted purchases, custom product pages, events, data types): edited as
* text, checked as JSON here and validated with the whole config when saving (the dry run). */
export function JsonField({ project, area, path, saved, label, hint, rows = 10 }: FieldBase & { path: string; saved: unknown; rows?: number }) {
const e = useEdits();
const savedText = saved === undefined || saved === null ? '' : JSON.stringify(saved, null, 2);
const pending = e.get(project.id, `config:${path}`);
const [text, setText] = useState(pending ? (pending.op.kind === 'config' && !pending.op.remove ? JSON.stringify(pending.op.value, null, 2) : '') : savedText);
useEffect(() => { if (!e.get(project.id, `config:${path}`)) setText(savedText); }, [savedText]); // eslint-disable-line react-hooks/exhaustive-deps
let err: string | null = null;
if (text.trim()) { try { JSON.parse(text); } catch (x) { err = (x as Error).message; } }
const fid = `ed-${path.replace(/[^A-Za-z0-9_-]/g, '-')}`;
return (
<div className={`ed-field${pending ? ' dirty' : ''}`}>
<div className="ed-head"><label htmlFor={fid}>{label}{pending && <span className="ed-dot" title={t('ed.unsavedField')} />}</label><span className="small muted mono-id">{path}</span></div>
<textarea id={fid} className="input mono" rows={rows} value={text} spellCheck={false} data-edit={`config:${path}`} aria-invalid={!!err || undefined}
onChange={ev => {
setText(ev.target.value);
const v = ev.target.value.trim();
if (!v) { e.set(project.id, { kind: 'config', path, remove: true }, area, saved ?? null); return; }
try { e.set(project.id, { kind: 'config', path, value: JSON.parse(v) }, area, saved ?? null); } catch { /* shown below; kept as text until it parses */ }
}} />
{hint && <div className="ed-hint small muted">{hint}</div>}
<Issues issues={err ? [{ level: 'error', msg: t('ed.notJson', { error: err }) }] : []} />
</div>
);
}
/** "Uncommitted in git" for the files an editor writes (storebound never commits). */
export function GitNote({ data, files }: { data: EditableResult; files: (string | null)[] }) {
if (!data.git.repo) return <span className="small muted row" style={{ gap: 6 }}><Icon name="folder" className="ic13" />{t('ed.noGit')}</span>;
const dirty = data.git.uncommitted.filter(f => files.includes(f));
if (!dirty.length) return <span className="small muted row" style={{ gap: 6 }}><Icon name="check" className="ic13" />{t('ed.gitClean')}</span>;
return <span className="row" style={{ gap: 6 }} data-git="uncommitted"><Chip tone="warn" icon="alert">{t('ed.gitDirty', { files: dirty.join(', ') })}</Chip></span>;
}
/** The plan of project.edit (old -> new, the checks, the diff per file) in the window. */
export function EditPlanView({ r }: { r: EditResult }) {
return (
<div className="ed-plan stack" data-edit-plan>
<ul className="ed-changes">
{r.changes.map((c, i) => (
<li key={i}><span className="strong">{c.what}</span>{c.also?.length ? <span className="small muted"> · {t('ed.also', { codes: c.also.join(', ') })}</span> : null}
<div className="small"><span className="ed-from">{c.from ?? t('ed.empty')}</span> <Icon name="arrowRight" className="ic13" /> <span className="ed-to">{c.to ?? t('ed.removed')}</span></div></li>
))}
</ul>
{r.issues.length > 0 && <Issues issues={r.issues.map(i => ({ level: i.level, msg: `${i.where}: ${i.msg}` }))} />}
{r.files.map(f => (
<details key={f.rel} className="ed-diff" open={r.files.length === 1}>
<summary className="small"><span className="mono-id">{f.rel}</span>{f.created ? ` · ${t('ed.newFile')}` : ''}</summary>
{f.masked ? <p className="small muted">{t('ed.masked')}</p> : <pre className="selectable">{f.diff.join('\n')}</pre>}
</details>
))}
</div>
);
}
/** The unsaved edits of one editor: count, undo / redo, discard, and review + save. */
export function EditBar({ project, area, data, files, onSaved }: { project: Project; area: EditArea; data: EditableResult; files: (string | null)[]; onSaved?: () => void }) {
const e = useEdits();
const w = useWrite(project.id);
const n = e.count(project.id, area);
const [review, setReview] = useState<{ loading: boolean; r: EditResult | null; error: string | null } | null>(null);
const ops = e.ops(project.id, area);
const opsJson = JSON.stringify(ops);
// a review shows the edits it was made for; typing more closes it
useEffect(() => { setReview(null); }, [opsJson]);
const check = useCallback(async () => {
setReview({ loading: true, r: null, error: null });
try {
const res = await api.call<ToolResult & { data?: EditResult }>('project.edit', project.id, { args: { ops: opsJson } });
setReview({ loading: false, r: (res.data as EditResult | undefined) ?? null, error: res.data ? null : failureText(res) });
} catch (x) { setReview({ loading: false, r: null, error: String((x as { message?: string }).message ?? x) }); }
}, [project.id, opsJson]);
return (
<div className={`ed-bar${n ? ' active' : ''}`} data-edit-bar={area}>
<div className="row between" style={{ gap: 10, flexWrap: 'wrap' }}>
<div className="row" style={{ gap: 8 }}>
{n ? <Chip tone="accent" icon="edit">{t('ed.unsaved', { count: n })}</Chip> : <span className="small muted">{t('ed.noChanges')}</span>}
<GitNote data={data} files={files} />
</div>
<div className="row" style={{ gap: 6 }}>
<button className="btn btn-sm btn-ghost" disabled={!e.canUndo(project.id)} onClick={() => e.undo(project.id)} title={t('ed.undo')}><Icon name="rotate" />{t('ed.undo')}</button>
<button className="btn btn-sm btn-ghost" disabled={!e.canRedo(project.id)} onClick={() => e.redo(project.id)} title={t('ed.redo')}><Icon name="refresh" />{t('ed.redo')}</button>
<button className="btn btn-sm btn-ghost" disabled={!n} onClick={() => e.clear(project.id, area, { undoable: true })}><Icon name="x" />{t('ed.discard')}</button>
<button className="btn btn-sm btn-primary" disabled={!n || review?.loading} onClick={check} data-action="review"><Icon name={review?.loading ? 'refresh' : 'eye'} className={review?.loading ? 'spin' : undefined} />{t('ed.review')}</button>
</div>
</div>
{review && !review.loading && (
<div className="ed-review">
{review.error && <Notice tone="bad">{review.error}</Notice>}
{review.r && <EditPlanView r={review.r} />}
{review.r && (
<div className="row" style={{ gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-sm btn-ghost" onClick={() => setReview(null)}>{t('ed.close')}</button>
<WriteButton run={w} primary icon="check" disabled={review.r.errors > 0 || !review.r.files.length}
hint={review.r.errors ? t('ed.fixFirst') : undefined}
w={{ method: 'project.edit', args: { ops: opsJson }, title: t('ed.save'), done: t('ed.saved'), after: () => { e.clear(project.id, area); setReview(null); onSaved?.(); } }}>{t('ed.save')}</WriteButton>
</div>
)}
</div>
)}
</div>
);
}
/** A store write with its dry run in the window first: the plan (old -> new per store and language), then Apply. */
export function ApplyPanel({ project, method, args = {}, label, icon = 'upload', done, disabled, hint, blocked, onApplied }: {
project: Project; method: string; args?: Record<string, unknown>; label: string; icon?: IconName; done: string; disabled?: boolean; hint?: string;
/** Unsaved edits that the store write would not include. */ blocked?: string | null;
/** After the real run went through. */ onApplied?: () => void;
}) {
const w = useWrite(project.id);
const [st, setSt] = useState<{ loading: boolean; text: string; ok: boolean } | null>(null);
const preview = async () => {
setSt({ loading: true, text: '', ok: false });
try {
const r = await api.call<ToolResult>(method, project.id, { args });
const lines = r.output.split('\n').map(l => l.replace(/^\d\d:\d\d:\d\d \[[^\]]+\] /, '')).filter(l => l.trim());
const data = r.data as { diff?: string[] } | undefined;
setSt({ loading: false, text: [...lines, ...(data?.diff ?? [])].join('\n') || t('ed.nothingToApply'), ok: r.ok });
} catch (x) { setSt({ loading: false, text: String((x as { message?: string }).message ?? x), ok: false }); }
};
return (
<div className="ed-apply" data-apply={method}>
<div className="row" style={{ gap: 8, flexWrap: 'wrap' }}>
<button className="btn btn-sm" disabled={disabled || st?.loading} onClick={preview} title={hint}><Icon name={st?.loading ? 'refresh' : 'eye'} className={st?.loading ? 'spin' : undefined} />{label}</button>
{blocked && <span className="small muted">{blocked}</span>}
</div>
{st && !st.loading && (
<div className="ed-review">
<pre className={`selectable ed-dry${st.ok ? '' : ' bad'}`}>{st.text}</pre>
<div className="row" style={{ gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-sm btn-ghost" onClick={() => setSt(null)}>{t('ed.close')}</button>
{st.ok && <WriteButton run={w} primary icon={icon} disabled={!!blocked} hint={blocked ?? undefined} w={{ method, args, title: label, done, after: () => { setSt(null); onApplied?.(); } }}>{t('ed.applyNow')}</WriteButton>}
</div>
</div>
)}
</div>
);
}