mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
feat(web): retain-alive master freeze — peeking prop + complete mutation-path audit (TASK-2172) (#977)
PLAN-2154 Phase 2 (Architecture E, D2/R6/R9/R11/R12), per Dave's HT-2176 Option A.
Adds a `peeking` prop to `ItemDetail` so the full-page master goes read-only +
ALIVE while a detail pane peeks beside it — retain-alive: the collab
PROVIDER/Y.Doc is never torn down/flushed. `peeking` defaults false → every
existing caller byte-identical (no host passes peeking={true} until TASK-2174).
Invariant (HT-2176 Option A): the freeze blocks only the INITIATION of a NEW
edit while peeking; a save/action the user STARTED before the pane opened
completes normally. Acceptance: "no NEW user-originated edit can be INITIATED
while peeking" (not "zero dispatch").
Core freeze (blocks NEW-action initiation): `mutationsEnabled = canEdit && !peeking`
(unit-tested `computeMutationsEnabled`) gates title / fields / tags / assign /
move / delete / relationship-add; ChildItems add/link + reorder INITIATION;
timeline comment compose + edit forms + upload; version-restore initiation;
archived restore; star (`!peeking`); Share + the whole quick-actions menu
(`!peeking`). The rich Editor gets `editable={!peeking}` + `peeking` in the
`{#key}` (remounts re-bound to the SAME live Y.Doc, shedding BlockDragHandle —
D2's mechanism); bubble/link popovers gated.
Pre-pane in-flight SAVES/ACTIONS complete (Option A) — no suppression:
- FIELD: `updateField` has NO peeking recheck; a value typed before the pane
opened fires its 500ms FieldEditor debounce and saves. NEW field input is
blocked at the UI (`FieldEditor readonly={!mutationsEnabled}`).
- TAG / RAW / COLLAB debounced saves complete (drain/save callbacks un-gated).
- CHILD REORDER: gated at INITIATION (`dragDisabled: !canEdit || frozen`), NOT
finalization — a drag already in progress when the pane opens persists fully
(removed the `frozen` reject from `handleFinalize`).
Documented deliberate exceptions/edges:
- Rich⇄Markdown mode FLIP is refused while peeking (it would teardown/recreate
the collab provider — retain-alive/D2); the CONTENT still flushes, only the
flip is deferred. Commented at both toggle handlers.
- BUG-2177 (accepted, tracked, sev=low): the D2 `{#key}` editor remount destroys
the initiating view, so an in-flight EDITOR-BOUND action (attachment upload,
rotate/crop, source-refresh, timeline-composer upload) is orphaned — bails
gracefully via `view.isDestroyed`/identity checks (no crash, no committed-
content loss). Referenced at every bail site.
Confirmed no teardown: `createCollabFlusher`'s save is only
`api.items.flushCollabContent`; `provider.destroy` lives solely in the
`{#key}`/forceRefreshNonce $effect cleanup.
R12 complete editor-DOM-handler audit — every custom handler that dispatches a
mutation is editable-gated (AttachmentUpload paste/drop/command; AttachmentImage
rotate/crop/toolbar; code-block + table clipboard CUT/PASTE — copy read-only;
htmlBlock edit-mode). These also close the same latent holes for view-only
viewers (already-broken mutations) — a bug fix, not a working-flow regression.
R9 clear-if-owner activeItem via `activeItemOwnedId` (byte-identical on a
cross-collection load failure) + re-assert on un-peek; R11 master title effect
yields while peeking. Timeline freeze via a `frozen` prop threaded ItemTimeline →
TimelineCommentCard / TimelineVersionCard. Tests: `computeMutationsEnabled` truth
table + FreezeProbe gate probe + a FieldSaveProbe mount test proving a pre-pane
debounced field save completes after the field goes read-only. Running-app
mutation-silence e2e is TASK-2175 (F). Not a Y.Doc node-spec change —
SCHEMA_VERSION/DefaultSchemaVersion unchanged.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
This commit is contained in:
@@ -37,6 +37,18 @@
|
||||
* proxy gate (svelte-dnd-action limitation, same as TASK-1106).
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
/**
|
||||
* PLAN-2154 Phase 2 / D2 / R12 (TASK-2172): master-freeze. When the
|
||||
* full-page host peeks a pane beside this item's ItemDetail, the master
|
||||
* passes `frozen={true}` so ALL ChildItems mutations (add/link-child +
|
||||
* reorder) freeze while child-row NAVIGATION stays live. Kept SEPARATE
|
||||
* from `canEdit` on purpose: add/link-child authorize off independent
|
||||
* target-collection / source-child capabilities (NOT the parent's edit
|
||||
* permission), so folding the freeze into `canEdit` would wrongly strip
|
||||
* add-child from a non-peeking user who lacks parent-edit but has those
|
||||
* capabilities. Defaults false → byte-identical for existing callers.
|
||||
*/
|
||||
frozen?: boolean;
|
||||
/**
|
||||
* Instance-scoped shadow of the OWNING ItemDetail's own dirty/
|
||||
* lastSaveTime (PLAN-2154 Phase 0 / R4, TASK-2156). ChildItems used to
|
||||
@@ -60,7 +72,7 @@
|
||||
onOpenTarget?: (target: PaneTarget) => void;
|
||||
}
|
||||
|
||||
let { wsSlug, username = '', itemSlug, itemId, parentFields, terminalStatuses, onChildrenChange, canEdit = true, selfDirty = false, selfLastSaveTime = 0, onOpenTarget }: Props = $props();
|
||||
let { wsSlug, username = '', itemSlug, itemId, parentFields, terminalStatuses, onChildrenChange, canEdit = true, frozen = false, selfDirty = false, selfLastSaveTime = 0, onOpenTarget }: Props = $props();
|
||||
|
||||
const defaultTerminal = ['done', 'completed', 'resolved', 'cancelled', 'rejected', 'wontfix', 'fixed', 'implemented', 'archived', 'disabled', 'deprecated'];
|
||||
const terminal = $derived(terminalStatuses ?? defaultTerminal);
|
||||
@@ -142,12 +154,23 @@
|
||||
groupData[status] = e.detail.items;
|
||||
isDragging = false;
|
||||
|
||||
// HT-2176 Option A (TASK-2172): gate reorder INITIATION, not finalization.
|
||||
// A NEW drag can't START while frozen (`dragDisabled: !canEdit || frozen`
|
||||
// on the dnd zone), but a drag already IN PROGRESS when the pane opens must
|
||||
// finalize and persist — so do NOT reject on `frozen` here. `!canEdit`
|
||||
// stays (the zone gate mirror; a non-editor never reaches finalize anyway).
|
||||
if (!canEdit) return;
|
||||
|
||||
const updates = groupData[status]
|
||||
.filter((i: any) => !i[SHADOW_ITEM_MARKER_PROPERTY_NAME])
|
||||
.map((item, index) => ({ id: item.id, sort_order: index }));
|
||||
|
||||
try {
|
||||
for (const { id, sort_order } of updates) {
|
||||
// HT-2176 Option A (TASK-2172): NO per-PATCH freeze recheck. The
|
||||
// reorder was INITIATED before peeking (the top guard blocks a NEW
|
||||
// one); breaking mid-loop would persist it only partially, leaving
|
||||
// inconsistent sort_orders. Let the initiated reorder finish.
|
||||
await api.items.update(wsSlug, id, { sort_order });
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -162,6 +185,9 @@
|
||||
// persists the changed rows via the same per-child update loop the drag
|
||||
// path uses. A canonical reload (SSE/sync) settles it afterward.
|
||||
async function reorderChild(status: string, child: Item, dir: ReorderDirection) {
|
||||
// Freeze guard (TASK-2172 / R14): mirror handleFinalize. The kebab is
|
||||
// hidden while `!canEdit || frozen`; this drops a straggler invocation.
|
||||
if (!canEdit || frozen) return;
|
||||
const grp = (groupData[status] ?? []).filter(
|
||||
(i: any) => !i[SHADOW_ITEM_MARKER_PROPERTY_NAME]
|
||||
);
|
||||
@@ -174,6 +200,8 @@
|
||||
const updates = reorderGroup(grp, child.id, dir);
|
||||
try {
|
||||
for (const u of updates) {
|
||||
// Option A (TASK-2172): no per-PATCH freeze recheck — a reorder
|
||||
// initiated pre-pane finishes fully (see handleFinalize).
|
||||
await api.items.update(wsSlug, u.item.id, { sort_order: u.sort_order });
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -331,8 +359,11 @@
|
||||
);
|
||||
});
|
||||
|
||||
// Entry "+ Add child" shows iff at least one mode is usable.
|
||||
let showAddChild = $derived(createTabEnabled || canLinkExisting);
|
||||
// Entry "+ Add child" shows iff at least one mode is usable — gated on the
|
||||
// INDEPENDENT target-collection / source-child capabilities (NOT the parent's
|
||||
// `canEdit`), so the freeze must not touch that logic. `&& !frozen` layers the
|
||||
// master-freeze on top without changing any non-peeking behavior (TASK-2172).
|
||||
let showAddChild = $derived((createTabEnabled || canLinkExisting) && !frozen);
|
||||
|
||||
// ── Form state ─────────────────────────────────────────────────────────
|
||||
let addOpen = $state(false);
|
||||
@@ -468,6 +499,9 @@
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
// Freeze guard (TASK-2172): the form is hidden while frozen, but drop a
|
||||
// straggler (e.g. an Enter keydown mid-freeze) so no child is created.
|
||||
if (frozen) return;
|
||||
const title = createTitle.trim();
|
||||
const collSlug = createCollSlug;
|
||||
if (!title || !collSlug || creating) return;
|
||||
@@ -578,7 +612,8 @@
|
||||
|
||||
async function confirmLink() {
|
||||
const cand = confirmCandidate;
|
||||
if (!cand || linking) return;
|
||||
// Freeze guard (TASK-2172): mirror submitCreate — no reparent while frozen.
|
||||
if (!cand || linking || frozen) return;
|
||||
// DR-6b: capture identity BEFORE the await.
|
||||
const reqWs = wsSlug;
|
||||
const reqSlug = itemSlug;
|
||||
@@ -632,7 +667,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if addOpen}
|
||||
<!-- `&& !frozen` unmounts an already-open add-child form the instant the
|
||||
parent freezes (TASK-2172): the master-freeze must leave no live mutation
|
||||
controls, not just block their writes. -->
|
||||
{#if addOpen && !frozen}
|
||||
<div class="add-child-form">
|
||||
<div class="add-child-tabs" role="tablist">
|
||||
<button
|
||||
@@ -776,7 +814,7 @@
|
||||
type: 'child-item',
|
||||
dropTargetClasses: ['drop-target'],
|
||||
delayTouchStart: touchDragDelayMs,
|
||||
dragDisabled: !canEdit
|
||||
dragDisabled: !canEdit || frozen
|
||||
}}
|
||||
onconsider={(e) => handleConsider(status, e)}
|
||||
onfinalize={(e) => handleFinalize(status, e)}
|
||||
@@ -806,7 +844,7 @@
|
||||
</span>
|
||||
{/if}
|
||||
</a>
|
||||
{#if canEdit}
|
||||
{#if canEdit && !frozen}
|
||||
<ItemActionsMenu
|
||||
item={child}
|
||||
label={child.title}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
interface Props {
|
||||
title?: string;
|
||||
detail?: string;
|
||||
onRetry: () => void;
|
||||
// Optional: when omitted the retry button is hidden entirely. The
|
||||
// PLAN-2154 master-freeze (TASK-2172) passes it undefined while peeking so
|
||||
// the frozen master can't trigger a provider-destroying reload.
|
||||
onRetry?: () => void;
|
||||
retryLabel?: string;
|
||||
}
|
||||
|
||||
@@ -20,9 +23,11 @@
|
||||
{#if detail}
|
||||
<p class="error-detail">{detail}</p>
|
||||
{/if}
|
||||
<button class="retry-btn" onclick={onRetry}>
|
||||
{retryLabel}
|
||||
</button>
|
||||
{#if onRetry}
|
||||
<button class="retry-btn" onclick={onRetry}>
|
||||
{retryLabel}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -123,6 +123,11 @@
|
||||
}
|
||||
|
||||
async function handleSaveNewAction() {
|
||||
// Recheck `canEdit` at dispatch time: if it flips false while the create
|
||||
// form is open (e.g. the PLAN-2154 master-freeze passing canEdit=false
|
||||
// while peeking, TASK-2172), refuse the api.collections.update. The form
|
||||
// itself unmounts on the same flip (see `{#if showCreateForm && canEdit}`).
|
||||
if (!canEdit) return;
|
||||
const label = newLabel.trim();
|
||||
const prompt = newPrompt.trim();
|
||||
if (!label || !prompt || saving) return;
|
||||
@@ -239,7 +244,7 @@
|
||||
{/snippet}
|
||||
|
||||
{#snippet actionList()}
|
||||
{#if showCreateForm}
|
||||
{#if showCreateForm && canEdit}
|
||||
{@render createForm()}
|
||||
{:else}
|
||||
{#each filtered as action (action.label)}
|
||||
|
||||
@@ -140,7 +140,12 @@
|
||||
// Clearing HTML prevents tiptap-markdown from re-decorating the paste target.
|
||||
event.clipboardData.setData('text/html', '');
|
||||
|
||||
if (isCut) {
|
||||
// Master-freeze / R12 (TASK-2172): the clipboard WRITE (copy) is read-only
|
||||
// and always allowed, but the CUT deletes content — a Yjs mutation. handleDOMEvents
|
||||
// fire even when the view is read-only (a peeking master OR a view-only
|
||||
// viewer), so gate the delete on `view.editable`. A read-only cut degrades
|
||||
// to a copy (same viewer bug-fix as the attachment gates).
|
||||
if (isCut && view.editable) {
|
||||
const tr = state.tr.delete(from, to);
|
||||
view.dispatch(tr);
|
||||
}
|
||||
@@ -236,7 +241,10 @@
|
||||
event.clipboardData.setData('text/plain', text);
|
||||
event.clipboardData.setData('text/html', '');
|
||||
|
||||
if (isCut) {
|
||||
// Master-freeze / R12 (TASK-2172): copy is read-only; the cut deletes
|
||||
// rows/cells (a Yjs mutation) — gate on `view.editable` (see
|
||||
// writeCodeBlockClipboard). A read-only cut degrades to a copy.
|
||||
if (isCut && view.editable) {
|
||||
if (selection instanceof CellSelection && selection.isRowSelection()) {
|
||||
// Whole-row selection: prefer structural row removal so undo restores
|
||||
// the rows intact in one step.
|
||||
@@ -319,6 +327,11 @@
|
||||
// round-trip fidelity and matches spreadsheet convention.
|
||||
function readTablePasteClipboard(view: any, event: ClipboardEvent): boolean {
|
||||
if (!event.clipboardData) return false;
|
||||
// Master-freeze / R12 (TASK-2172): a TSV paste REPLACES table cells — a Yjs
|
||||
// mutation. handleDOMEvents fire even when the view is read-only (a peeking
|
||||
// master OR a view-only viewer), so bail while read-only and let the default
|
||||
// (contentEditable-blocked) paste path handle it — no cell mutation lands.
|
||||
if (!view.editable) return false;
|
||||
const { state } = view;
|
||||
|
||||
// Guard 1: anchor must be inside a table.
|
||||
|
||||
@@ -315,6 +315,16 @@ export const AttachmentImage = Node.create<AttachmentImageOptions>({
|
||||
};
|
||||
|
||||
const swapNodeUuid = (newId: string): void => {
|
||||
// Master-freeze / R12 (TASK-2172); ACCEPTED tracked edge BUG-2177:
|
||||
// runRotate/runCrop gate editability at CLICK time, but the transform
|
||||
// awaits a network round-trip during which the master can begin peeking
|
||||
// — remounting the editor (editable=false) via the `{#key peeking}`,
|
||||
// which destroys THIS NodeView's editor. Re-check right before the
|
||||
// dispatch: a destroyed view would throw, and a read-only one must not
|
||||
// receive the Yjs transaction the freeze forbids. The server-side
|
||||
// transform still ran; only its doc reference is dropped (the tracked
|
||||
// BUG-2177 tradeoff — no crash, no committed-content loss).
|
||||
if (editor.isDestroyed || !editor.isEditable) return;
|
||||
const pos = typeof getPos === 'function' ? getPos() : null;
|
||||
if (pos == null) return;
|
||||
// Replace the node's UUID at its current position.
|
||||
@@ -342,6 +352,12 @@ export const AttachmentImage = Node.create<AttachmentImageOptions>({
|
||||
};
|
||||
|
||||
const runRotate = async (degrees: 90 | 180 | 270): Promise<void> => {
|
||||
// Master-freeze / R12 (PLAN-2154 / TASK-2172): tiptap's `editable`
|
||||
// flag does NOT gate this NodeView toolbar, so a read-only editor
|
||||
// (e.g. a peeking full-page master) could otherwise rotate the
|
||||
// attachment and dispatch a Yjs transaction. Refuse the transform
|
||||
// unless the editor is currently editable.
|
||||
if (!editor.isEditable) return;
|
||||
// Snapshot uuid at click time. Now that update() keeps the
|
||||
// NodeView alive, currentUuid can shift while transform is
|
||||
// in flight (e.g. peer rotates the same image). Without the
|
||||
@@ -364,6 +380,10 @@ export const AttachmentImage = Node.create<AttachmentImageOptions>({
|
||||
};
|
||||
|
||||
const runCrop = async (): Promise<void> => {
|
||||
// Master-freeze / R12 (PLAN-2154 / TASK-2172): same editable gate as
|
||||
// runRotate — a read-only editor must not open the crop modal or
|
||||
// dispatch the resulting transform.
|
||||
if (!editor.isEditable) return;
|
||||
// Snapshot uuid + alt at click time. The crop rect the user
|
||||
// chooses is bound to the IMAGE-AT-OPEN-TIME, so any drift
|
||||
// (peer rotated/cropped while the modal is open) must
|
||||
@@ -390,6 +410,12 @@ export const AttachmentImage = Node.create<AttachmentImageOptions>({
|
||||
rect = null;
|
||||
}
|
||||
if (rect == null) return;
|
||||
// Master-freeze / R14 (TASK-2172); tracked edge BUG-2177: peeking can
|
||||
// begin WHILE the crop modal is open. Re-check editability after it
|
||||
// resolves — before the server-side transform — so a frozen/remounted
|
||||
// master starts no transform (a crop initiated pre-pane whose editor
|
||||
// remounts is the accepted BUG-2177 orphan: no crash, no content loss).
|
||||
if (editor.isDestroyed || !editor.isEditable) return;
|
||||
// Live node moved to a different uuid while the modal was
|
||||
// open — the rect doesn't apply. Drop silently rather than
|
||||
// mis-cropping the new image.
|
||||
@@ -468,6 +494,13 @@ export const AttachmentImage = Node.create<AttachmentImageOptions>({
|
||||
},
|
||||
selectNode() {
|
||||
wrapper.classList.add('attachment-image-selected');
|
||||
// Master-freeze / R12 (TASK-2172): don't surface the rotate/crop
|
||||
// toolbar on a read-only editor — a peeking master OR a view-only
|
||||
// viewer. Its buttons no-op via the editable gate in
|
||||
// runRotate/runCrop; hiding it keeps the freeze visually honest.
|
||||
// Closing this for viewers too is a bug fix (their transforms had
|
||||
// no provider to sync and 403'd on save), not a working-flow change.
|
||||
if (!editor.isEditable) return;
|
||||
const tb = ensureToolbar();
|
||||
tb.classList.remove('attachment-image-toolbar-hidden');
|
||||
},
|
||||
|
||||
@@ -171,6 +171,22 @@ function startUpload(
|
||||
opts
|
||||
.upload(file)
|
||||
.then((result) => {
|
||||
// Master-freeze / R12 (TASK-2172); ACCEPTED tracked edge BUG-2177 — the
|
||||
// bytes land server-side but the reference isn't inserted (no crash, no
|
||||
// committed-content loss): the view can be torn down or turned
|
||||
// read-only WHILE this upload is in flight — e.g. a peeking master
|
||||
// remounts its editor (editable=false) via the `{#key peeking}`,
|
||||
// destroying THIS view. Never dispatch into a destroyed/read-only view:
|
||||
// dispatch on a destroyed view throws, and inserting into a frozen one
|
||||
// is exactly the mutation the freeze forbids. Drop the result (the
|
||||
// bytes are already stored server-side; the reference is simply not
|
||||
// inserted) and clean up the placeholder if the same view still lives.
|
||||
if (view.isDestroyed || !view.editable) {
|
||||
if (!view.isDestroyed) {
|
||||
view.dispatch(view.state.tr.setMeta(pluginKey, { remove: id } satisfies UploadAction));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const state = pluginKey.getState(view.state);
|
||||
const entry = state?.uploads.get(id);
|
||||
if (!entry) return; // placeholder gone — drop the upload silently
|
||||
@@ -181,7 +197,15 @@ function startUpload(
|
||||
view.dispatch(tr);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
// Same teardown/freeze guard as the success path (TASK-2172): a
|
||||
// destroyed view can't accept the placeholder-cleanup dispatch, and a
|
||||
// read-only master (peeking) must not surface an upload-error alert for
|
||||
// an upload it never let the user start. The cleanup dispatch is a
|
||||
// meta-only transaction (no doc step → no Yjs op), so it's safe to run
|
||||
// on a live view; only skip it when the view is gone.
|
||||
if (view.isDestroyed) return;
|
||||
view.dispatch(view.state.tr.setMeta(pluginKey, { remove: id } satisfies UploadAction));
|
||||
if (!view.editable) return;
|
||||
const message = err instanceof Error ? err.message : String(err ?? 'Upload failed');
|
||||
opts.onError?.(filename, message);
|
||||
});
|
||||
@@ -260,6 +284,15 @@ export function attachmentUploadPlugin(opts: AttachmentUploadOptions): Plugin<Up
|
||||
},
|
||||
handleDOMEvents: {
|
||||
paste(view, event) {
|
||||
// Master-freeze / R12 (PLAN-2154 / TASK-2172): ProseMirror runs
|
||||
// custom DOM handlers even when the view is NOT editable, so a
|
||||
// paste onto a read-only editor (a peeking full-page master OR a
|
||||
// genuine view-only viewer) would otherwise upload + dispatch a
|
||||
// Yjs insertion. Bail while read-only. NOTE: this also closes the
|
||||
// same latent hole for view-only viewers, whose attachment
|
||||
// mutations were already broken (no provider to sync; the content
|
||||
// PATCH 403s) — a bug fix, not a regression of any working flow.
|
||||
if (!view.editable) return false;
|
||||
const files = filesFromPaste(event as ClipboardEvent);
|
||||
if (files.length === 0) return false; // let other handlers process the paste
|
||||
event.preventDefault();
|
||||
@@ -270,6 +303,8 @@ export function attachmentUploadPlugin(opts: AttachmentUploadOptions): Plugin<Up
|
||||
return true;
|
||||
},
|
||||
drop(view, event) {
|
||||
// Same editable gate as paste (TASK-2172 / R12).
|
||||
if (!view.editable) return false;
|
||||
const files = filesFromDrop(event as DragEvent);
|
||||
if (files.length === 0) return false;
|
||||
const pos = dropPosition(view, event as DragEvent);
|
||||
@@ -318,6 +353,10 @@ export const AttachmentUpload = Extension.create<AttachmentUploadOptions>({
|
||||
uploadAttachments:
|
||||
(files: File[]) =>
|
||||
({ view }) => {
|
||||
// Master-freeze / R12 (TASK-2172): refuse programmatic uploads
|
||||
// on a read-only editor (the invoking toolbar/slash surfaces are
|
||||
// already hidden while frozen — defense-in-depth).
|
||||
if (!view.editable) return false;
|
||||
if (!files.length) return false;
|
||||
const pos = view.state.selection.from;
|
||||
for (const file of files) {
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
import { repairDeadItemLastRoute } from '$lib/collections/paneUrlParams';
|
||||
import { isSamePaneTarget, breadcrumbParentTarget } from '$lib/collections/paneTarget';
|
||||
import { readPaneState } from '$lib/collections/paneController';
|
||||
import { computeMutationsEnabled } from './mutationGate';
|
||||
import { shouldOpenInPane } from '$lib/components/collections/itemCardClick';
|
||||
import { starredStore } from '$lib/stores/starred.svelte';
|
||||
import { titleStore } from '$lib/stores/title.svelte';
|
||||
@@ -86,6 +87,7 @@
|
||||
collSlug,
|
||||
ref,
|
||||
embedded = false,
|
||||
peeking = false,
|
||||
onClose,
|
||||
onGone,
|
||||
onNavigateAway,
|
||||
@@ -98,6 +100,14 @@
|
||||
collSlug: string;
|
||||
ref: string;
|
||||
embedded?: boolean;
|
||||
// PLAN-2154 Phase 2 / D2 (TASK-2172): retain-alive master freeze. When
|
||||
// the full-page host mounts a detail pane beside this master it sets
|
||||
// `peeking={!!openItemRef}` (wired in TASK-2174), turning the master
|
||||
// read-only WITHOUT tearing down its live collab provider. Drives
|
||||
// `mutationsEnabled` below (gates every mutation surface), the editor's
|
||||
// `editable`/remount, and the timeline/ChildItems freeze. Defaults false
|
||||
// so every existing (non-host) caller is byte-identical.
|
||||
peeking?: boolean;
|
||||
onClose?: () => void;
|
||||
onGone?: () => void;
|
||||
onNavigateAway?: (url: string) => void;
|
||||
@@ -170,6 +180,17 @@
|
||||
// counter — not reactive; it only fences async writes.
|
||||
let loadGeneration = 0;
|
||||
|
||||
// R9 teardown ownership (TASK-2172). The onDestroy clear-if-owner compares
|
||||
// the global `collectionStore.activeItem` against the id THIS instance last
|
||||
// set active — NOT against the reactive `item`, which a cross-collection load
|
||||
// reassigns BEFORE it resolves (and may fail to resolve) the collection,
|
||||
// leaving `item` ahead of `activeItem` and desyncing the compare. Tracking
|
||||
// the id we actually set active keeps single-instance teardown byte-identical
|
||||
// (always clears, like the pre-R9 unconditional clear) while still not
|
||||
// nulling a DIFFERENT instance's activeItem on the full-page host. Plain
|
||||
// `let` — a handler-only tracker, never reactive.
|
||||
let activeItemOwnedId: string | null = null;
|
||||
|
||||
// Per-switch fetch memoization (TASK-2120). The workspace's members and
|
||||
// agent roles are workspace-invariant, yet loadData re-fetched them on
|
||||
// every row-click and every j/k keystroke. Cache them on this persistent
|
||||
@@ -569,6 +590,15 @@
|
||||
// archived gate that forces canEdit false — otherwise the Restore CTA
|
||||
// would render for read-only viewers and just 403 on click (Codex).
|
||||
let canRestore = $derived(item ? workspaceStore.canEditItem(item) : false);
|
||||
// PLAN-2154 Phase 2 / D2 / R12 (TASK-2172): the master-freeze gate. Every
|
||||
// local/user-originated mutation surface below (title / fields / assign /
|
||||
// move / delete / relationships / ChildItems / raw editor / editor mutation
|
||||
// UI / timeline) gates on this instead of raw `canEdit`, so a peeking master
|
||||
// (full-page host with a pane open) is a complete read-only freeze while its
|
||||
// collab provider stays LIVE. Single source of truth: `computeMutationsEnabled`
|
||||
// (unit-tested). `peeking` defaults false → `mutationsEnabled === canEdit` for
|
||||
// every non-host caller (byte-identical).
|
||||
let mutationsEnabled = $derived(computeMutationsEnabled(canEdit, peeking));
|
||||
$effect(() => {
|
||||
if (wsSlug && collSlug && itemSlug) {
|
||||
loadData();
|
||||
@@ -585,13 +615,70 @@
|
||||
// yields while the pane is mounted and reclaims the tab title the instant
|
||||
// the pane closes and this component unmounts. (Embedded ItemDetail is
|
||||
// only ever mounted when `?item=` is set, so the two never write at once.)
|
||||
//
|
||||
// R11 (TASK-2172): on the full-page host a peeking master and its embedded
|
||||
// pane are BOTH mounted ItemDetail instances that would write the tab title.
|
||||
// The pane wins — so the peeking master yields here (mirrors the collection
|
||||
// page's `!openItemRef` gate). `peeking` defaults false, so the non-host
|
||||
// full-page view still owns the title exactly as before.
|
||||
$effect(() => {
|
||||
if (peeking) return;
|
||||
titleStore.setPageTitle({
|
||||
section: null,
|
||||
item: item ? (formatItemRef(item) || null) : null,
|
||||
});
|
||||
});
|
||||
|
||||
// PLAN-2154 Phase 2 (TASK-2172): master-freeze TRANSITION effects.
|
||||
// • Peeking BEGIN (R6): the raw-markdown editor goes read-only, but a save
|
||||
// it queued just BEFORE the freeze is still sitting in the
|
||||
// `rawContentSaver` debounce. Drain it synchronously (flushNow) and
|
||||
// cancel the debounce so no REST `item.content` write lands mid-freeze.
|
||||
// Also close any open in-place title edit so its textarea can't linger
|
||||
// editable behind the freeze.
|
||||
// • Peeking END (R9): the master never re-runs loadData on pane close, so
|
||||
// it must reclaim the shared `collectionStore.activeItem` the pane's
|
||||
// clear-if-owner teardown nulled — otherwise layout self-save suppression
|
||||
// and ChildItems.defaultCollSlug degrade.
|
||||
// Only `peeking` is a tracked dep — `wasPeeking` is a plain prev-flag (not
|
||||
// $state, so CONVE-1688 doesn't apply) and `item`/saver reads are untracked.
|
||||
// For non-host callers `peeking` is constant false, so this runs once at
|
||||
// mount and does nothing (byte-identical).
|
||||
let wasPeeking = false;
|
||||
$effect(() => {
|
||||
const nowPeeking = !!peeking;
|
||||
const began = !wasPeeking && nowPeeking;
|
||||
const ended = wasPeeking && !nowPeeking;
|
||||
wasPeeking = nowPeeking;
|
||||
if (began) {
|
||||
untrack(() => {
|
||||
// Freeze blocks NEW edits (HT-2176 Option A). Dismiss any open
|
||||
// mutation surface so no new edit can be COMPLETED while peeking —
|
||||
// an in-place title edit and the share / edit-collection / move /
|
||||
// add-relationship menus. A debounced save the user INITIATED before
|
||||
// the pane opened is deliberately NOT touched here: it completes
|
||||
// normally (it persists the master's own pre-pane content, doesn't
|
||||
// collide with the pane, and — confirmed — never tears down the
|
||||
// collab provider). TASK-2172.
|
||||
editingTitle = false;
|
||||
shareDialogOpen = false;
|
||||
editCollectionOpen = false;
|
||||
showMoveMenu = false;
|
||||
showAddLink = false;
|
||||
});
|
||||
} else if (ended) {
|
||||
untrack(() => {
|
||||
// Un-peek: the master reclaims the shared activeItem (R9). Nothing
|
||||
// to re-flush — under Option A pre-pane saves already completed on
|
||||
// their own while peeking; the freeze only ever blocked NEW edits.
|
||||
if (item) {
|
||||
collectionStore.setActiveItem(item);
|
||||
activeItemOwnedId = item.id;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Sync coordinator — refresh item data on tab resume
|
||||
let unsubscribeSync: (() => void) | null = null;
|
||||
let unsubscribeSSE: (() => void) | null = null;
|
||||
@@ -871,7 +958,17 @@
|
||||
loadGeneration++;
|
||||
editorStore.resetForDoc();
|
||||
localDirty = false;
|
||||
collectionStore.setActiveItem(null);
|
||||
// R9 (TASK-2172): clear-if-owner. `collectionStore.activeItem` is a
|
||||
// module singleton shared with the layout (self-save suppression) and
|
||||
// ChildItems (defaultCollSlug). On the full-page host the docked PANE and
|
||||
// the master are two ItemDetail instances; the pane's teardown must NOT
|
||||
// null the master's activeItem. Only clear it when THIS instance still
|
||||
// owns it — the master's own teardown (page nav) still clears, and its
|
||||
// un-peek effect re-asserts (below). Pre-host callers own it uniquely, so
|
||||
// this stays a plain clear for them.
|
||||
if (collectionStore.activeItem?.id === activeItemOwnedId) {
|
||||
collectionStore.setActiveItem(null);
|
||||
}
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
@@ -1076,6 +1173,7 @@
|
||||
collection = collData;
|
||||
}
|
||||
collectionStore.setActiveItem(itemData);
|
||||
activeItemOwnedId = itemData.id;
|
||||
editorStore.resetForDoc();
|
||||
localDirty = false;
|
||||
|
||||
@@ -1395,6 +1493,11 @@
|
||||
// in that case). Per Codex review rounds 1 and 2 of TASK-1376.
|
||||
function retryCollabSync() {
|
||||
if (!item) return;
|
||||
// Master-freeze / R14 (TASK-2172): the retry bumps forceRefreshNonce,
|
||||
// whose $effect cleanup FLUSHES + DESTROYS the provider/Y.Doc — a teardown
|
||||
// D2 forbids while peeking (retain-alive). Refuse it; the affordance is
|
||||
// also hidden below (onRetry passed undefined while peeking).
|
||||
if (peeking) return;
|
||||
staleConnecting = false;
|
||||
forceRefreshNonce += 1;
|
||||
}
|
||||
@@ -1866,7 +1969,7 @@
|
||||
});
|
||||
|
||||
async function startEditTitle() {
|
||||
if (!item || !canEdit) return;
|
||||
if (!item || !mutationsEnabled) return;
|
||||
titleDraft = item.title;
|
||||
editingTitle = true;
|
||||
// Wait for the DOM to render the textarea, then focus + select all
|
||||
@@ -1913,6 +2016,9 @@
|
||||
|
||||
async function saveTitle() {
|
||||
editingTitle = false;
|
||||
// Master-freeze guard (TASK-2172): a blur can fire saveTitle after the
|
||||
// freeze began; drop the write so a peeking master never PATCHes a title.
|
||||
if (!mutationsEnabled) return;
|
||||
if (!item || titleDraft.trim() === item.title) return;
|
||||
// Capture the target item + generation BEFORE the await. A blur-fired
|
||||
// saveTitle can resolve AFTER the pane switched to another item (click
|
||||
@@ -1946,6 +2052,13 @@
|
||||
}
|
||||
|
||||
async function updateField(key: string, value: any) {
|
||||
// HT-2176 Option A (TASK-2172): NO `mutationsEnabled` recheck. NEW field
|
||||
// input is blocked at the UI (`FieldEditor readonly={!mutationsEnabled}`),
|
||||
// so this only ever runs for a value the user typed BEFORE the pane opened
|
||||
// — FieldEditor's 500ms debounce fires the pending onchange after peeking
|
||||
// began (peeking doesn't re-prop `value`, so its cancel-on-external-change
|
||||
// $effect leaves the timer armed). That pre-pane save must COMPLETE, not be
|
||||
// suppressed (mirrors the tag/raw pre-pane saves).
|
||||
if (!item) return;
|
||||
const updated = { ...fields, [key]: value };
|
||||
const payload = JSON.stringify(updated);
|
||||
@@ -2054,7 +2167,7 @@
|
||||
const tagSavers = new Map<string, TagSaver>();
|
||||
|
||||
function updateTags(newTags: string[]) {
|
||||
if (!item) return;
|
||||
if (!item || !mutationsEnabled) return;
|
||||
const targetItem = item;
|
||||
const targetWs = wsSlug;
|
||||
// Optimistic so chips react instantly.
|
||||
@@ -2086,6 +2199,10 @@
|
||||
saveStatus = 'saving';
|
||||
try {
|
||||
while (saver.pending !== null) {
|
||||
// HT-2176 Option A (TASK-2172): NO peeking recheck in the drain. NEW
|
||||
// tag input is blocked at the UI (`TagInput readonly={!mutationsEnabled}`)
|
||||
// + the `updateTags` guard, so this only drains a tag edit the user
|
||||
// made BEFORE the pane opened — allowed to complete.
|
||||
const toSave = saver.pending;
|
||||
saver.pending = null;
|
||||
const fresh = await api.items.update(saver.ws, saver.itemId, {
|
||||
@@ -2209,6 +2326,11 @@
|
||||
// close the race against concurrent field edits.
|
||||
const latest = await api.items.get(targetWs, targetItem.id);
|
||||
if (switchedAway(targetItem, gen)) return;
|
||||
// HT-2176 Option A (TASK-2172): NO peeking recheck. New imports are
|
||||
// blocked at the trigger (refreshFromSource / handleImportInserted gate
|
||||
// on `mutationsEnabled`); this only ever runs as the automatic
|
||||
// continuation of an import the user STARTED before the pane opened, so
|
||||
// it completes normally (stamping the master's own pre-pane content).
|
||||
const latestFields = parseFields(latest);
|
||||
const merged = {
|
||||
...latestFields,
|
||||
@@ -2245,7 +2367,7 @@
|
||||
// as source-backed if it happened to not have synced yet. Per
|
||||
// Codex review round 4.
|
||||
function handleImportInserted(meta: ImportURLResponse, ctx: { wasEmpty: boolean }) {
|
||||
if (!item) return;
|
||||
if (!item || !mutationsEnabled) return;
|
||||
const alreadyStamped =
|
||||
typeof fields.pad_source_url === 'string' && fields.pad_source_url.length > 0;
|
||||
if (!ctx.wasEmpty || alreadyStamped) return;
|
||||
@@ -2261,7 +2383,7 @@
|
||||
let refreshing = $state(false);
|
||||
async function refreshFromSource() {
|
||||
const url = fields.pad_source_url;
|
||||
if (!url || typeof url !== 'string' || !item || !editorInstance) return;
|
||||
if (!url || typeof url !== 'string' || !item || !editorInstance || !mutationsEnabled) return;
|
||||
// Capture the item + editor instance before any awaits. A user
|
||||
// who confirms the refresh and then navigates to another item
|
||||
// before the fetch returns would otherwise have their NEW item
|
||||
@@ -2284,7 +2406,11 @@
|
||||
const resp = await api.importURL(url);
|
||||
// Bail if the user navigated to a different item — applying
|
||||
// the refresh now would target the wrong document AND stamp
|
||||
// the wrong source URL.
|
||||
// the wrong source URL. The `editorInstance !== targetEditor` arm
|
||||
// ALSO covers the ACCEPTED tracked edge BUG-2177 (TASK-2172): a
|
||||
// source-refresh confirmed pre-pane whose editor is REMOUNTED by the
|
||||
// peeking `{#key peeking}` — the captured `targetEditor` is stale, so
|
||||
// we drop the content replacement (no crash, no committed-content loss).
|
||||
if (switchedAway(targetItem, gen) || editorInstance !== targetEditor) {
|
||||
return;
|
||||
}
|
||||
@@ -2320,7 +2446,7 @@
|
||||
}
|
||||
|
||||
async function updateAssignedUser(userId: string | null) {
|
||||
if (!item) return;
|
||||
if (!item || !mutationsEnabled) return;
|
||||
// Capture target + generation before the await; drop the post-await
|
||||
// write if the pane switched items mid-request (PLAN-2105 / TASK-2112;
|
||||
// coordinator P1).
|
||||
@@ -2346,7 +2472,7 @@
|
||||
}
|
||||
|
||||
async function updateAgentRole(roleId: string | null) {
|
||||
if (!item) return;
|
||||
if (!item || !mutationsEnabled) return;
|
||||
// Capture target + generation before the await; drop the post-await
|
||||
// write if the pane switched items mid-request (PLAN-2105 / TASK-2112;
|
||||
// coordinator P1).
|
||||
@@ -2390,6 +2516,13 @@
|
||||
// canonical for live state; items.content stays "reasonably
|
||||
// fresh" for search / share-page / API consumers. Per
|
||||
// TASK-1260 / PLAN-1248.
|
||||
//
|
||||
// Deliberately NOT peeking-gated (TASK-2172 / D2): while peeking the
|
||||
// editor is `editable=false`, so the USER can't type — this fires only
|
||||
// for REMOTE collab ops syncing into the still-live Y.Doc. Persisting
|
||||
// that canonical remote state as a background snapshot is exactly the
|
||||
// retain-alive posture (the acceptance carves out remote sync); gating
|
||||
// it here would half-tear-down the provider the freeze must keep whole.
|
||||
if (collabProvider) {
|
||||
editorStore.setDirty(true);
|
||||
localDirty = true;
|
||||
@@ -2622,6 +2755,11 @@
|
||||
debounceMs: 1200,
|
||||
save: (markdown, { keepalive }) => {
|
||||
if (!item) return;
|
||||
// HT-2176 Option A (TASK-2172): NO peeking recheck here. NEW raw input
|
||||
// is blocked at the editor (`readonly={!canEdit || peeking}`), so this
|
||||
// only ever fires for a debounced save the user INITIATED before the
|
||||
// pane opened — which is allowed to complete (persists the master's own
|
||||
// pre-pane content; no provider teardown).
|
||||
// Capture the item id this PATCH was scoped to so a
|
||||
// late-arriving response after navigation to a new item
|
||||
// can't apply the old item's snapshot to the new page
|
||||
@@ -2703,6 +2841,10 @@
|
||||
let rawSeedMarkdown = $state<string | null>(null);
|
||||
|
||||
function handleRawContentUpdate(markdown: string) {
|
||||
// Master-freeze guard (TASK-2172 / R6): the raw editor is `readonly` while
|
||||
// peeking, so this shouldn't fire — but drop any straggler so no queued
|
||||
// REST `item.content` save is armed behind the freeze.
|
||||
if (!mutationsEnabled) return;
|
||||
// Cancel any pending LEGACY (non-collab rich) debounce so it can't
|
||||
// fire a stale save over this raw edit — preserves the shared-timer
|
||||
// non-trample the two paths had before TASK-2029 split the raw
|
||||
@@ -2987,12 +3129,16 @@
|
||||
// currently shown — otherwise A's restore would render permanently under
|
||||
// ?item=B (PLAN-2105 / TASK-2112; coordinator). The descendant also
|
||||
// fences its own onRestore by itemSlug (belt-and-suspenders).
|
||||
// HT-2176 Option A (TASK-2172): NO peeking guard. NEW restores are blocked
|
||||
// at the card (hidden while `frozen`); this callback only adopts the result
|
||||
// of a restore the user CONFIRMED before the pane opened — a completed
|
||||
// server-side action whose result must land, not be discarded.
|
||||
if (!item || item.id !== updatedItem.id) return;
|
||||
item = withInflightTags(updatedItem);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!item) return;
|
||||
if (!item || !mutationsEnabled) return;
|
||||
// Capture identity before the await. The DELETE targets `targetItem.id`
|
||||
// (= A), but the post-await feedback must be fenced: if the pane
|
||||
// switched to B while A's delete was in flight, calling handleGone()
|
||||
@@ -3022,7 +3168,10 @@
|
||||
// endpoint can 409 if the slug/invocation_slug was reclaimed while
|
||||
// archived — surface that message the same way other handlers do. TASK-1829.
|
||||
async function handleRestore() {
|
||||
if (!item || restoring) return;
|
||||
// `canEdit` is forced false for an archived item, so restore gates on
|
||||
// `canRestore` — freeze it with `!peeking` (not `mutationsEnabled`), the
|
||||
// same split the render gate uses (TASK-2172).
|
||||
if (!item || restoring || peeking) return;
|
||||
// Capture target + generation before the awaits; drop the post-await
|
||||
// write if the pane switched items mid-request (PLAN-2105 / TASK-2112;
|
||||
// coordinator "gate all post-await writes").
|
||||
@@ -3104,7 +3253,7 @@
|
||||
}
|
||||
|
||||
async function handleDeleteLink(linkId?: string) {
|
||||
if (!linkId || !item) return;
|
||||
if (!linkId || !item || !mutationsEnabled) return;
|
||||
// Capture identity BEFORE the awaits. The refresh GET must use the
|
||||
// captured slug (not the live `itemSlug`, which an A→B→C switch would
|
||||
// have advanced) and the result must be dropped if we switched away —
|
||||
@@ -3194,7 +3343,7 @@
|
||||
}
|
||||
|
||||
async function handleCreateLink(target: Item) {
|
||||
if (!item) return;
|
||||
if (!item || !mutationsEnabled) return;
|
||||
// Capture the SOURCE item (the one being edited) + generation before the
|
||||
// awaits. `target` is the link target chosen from search; `sourceItem`
|
||||
// is the current item. Use the captured slug for the refresh GET and
|
||||
@@ -3229,7 +3378,7 @@
|
||||
}
|
||||
|
||||
async function handleMove(targetSlug: string) {
|
||||
if (!item || moving) return;
|
||||
if (!item || moving || !mutationsEnabled) return;
|
||||
moving = true;
|
||||
showMoveMenu = false;
|
||||
// Capture FULL route identity (workspace, username, source
|
||||
@@ -3528,7 +3677,7 @@
|
||||
{/if}
|
||||
<span class="archived-hint">It's read-only until restored.</span>
|
||||
</div>
|
||||
{#if canRestore}
|
||||
{#if canRestore && !peeking}
|
||||
<button class="archived-restore-btn" onclick={handleRestore} disabled={restoring}>
|
||||
{restoring ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
@@ -3551,12 +3700,13 @@
|
||||
onkeydown={handleTitleKeydown}
|
||||
oninput={(e) => autoResizeTitle(e.currentTarget)}
|
||||
></textarea>
|
||||
{:else if canEdit}
|
||||
{:else if mutationsEnabled}
|
||||
<button class="title" onclick={startEditTitle}>
|
||||
{item.title}
|
||||
</button>
|
||||
{:else}
|
||||
<!-- Read-only title (PLAN-1100 / TASK-1105) — no click-to-edit. -->
|
||||
<!-- Read-only title (PLAN-1100 / TASK-1105; frozen while peeking,
|
||||
TASK-2172) — no click-to-edit. -->
|
||||
<h1 class="title title-readonly">{item.title}</h1>
|
||||
{/if}
|
||||
{#if typeof fields.pad_source_url === 'string' && fields.pad_source_url}
|
||||
@@ -3580,7 +3730,7 @@
|
||||
Refresh button so the import history is still
|
||||
discoverable. (Per Codex review round 1.)
|
||||
-->
|
||||
{#if canEdit && !rawMode}
|
||||
{#if mutationsEnabled && !rawMode}
|
||||
<button
|
||||
type="button"
|
||||
class="source-chip"
|
||||
@@ -3637,15 +3787,24 @@
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="meta-actions">
|
||||
<!-- Star toggles a REST mutation (starredStore.toggle) and is otherwise
|
||||
available to viewers too, so it gates on `peeking` ONLY — NOT
|
||||
`mutationsEnabled` (which folds in canEdit and would wrongly disable
|
||||
a non-peeking viewer's star, breaking byte-identity). TASK-2172. -->
|
||||
<button
|
||||
class="action-btn star-btn"
|
||||
class:starred={starredStore.isStarred(item.id)}
|
||||
onclick={() => item && starredStore.toggle(wsSlug, item.slug, item.id)}
|
||||
disabled={peeking}
|
||||
onclick={() => { if (peeking || !item) return; starredStore.toggle(wsSlug, item.slug, item.id); }}
|
||||
title={starredStore.isStarred(item.id) ? 'Unstar' : 'Star'}
|
||||
>
|
||||
{starredStore.isStarred(item.id) ? '★' : '☆'}
|
||||
</button>
|
||||
{#if collection && (quickActions.length > 0 || isOwner)}
|
||||
<!-- Quick-actions trigger gated on `!peeking` (TASK-2172): unmounts the
|
||||
whole menu while peeking, dismissing any open dropdown/create-form
|
||||
and blocking the owner create/manage collection-schema mutations.
|
||||
`&& !peeking` → byte-identical when not peeking. -->
|
||||
{#if collection && (quickActions.length > 0 || isOwner) && !peeking}
|
||||
<!-- {#key itemSlug}: structural containment (PLAN-2105 / TASK-2112).
|
||||
Remount this item-scoped menu on every item switch so any
|
||||
in-flight quick-action continuation is discarded. Keyed on
|
||||
@@ -3731,7 +3890,7 @@
|
||||
📎 {backlinksCount}
|
||||
</button>
|
||||
{/if}
|
||||
{#if canEdit}
|
||||
{#if mutationsEnabled}
|
||||
<div class="move-wrapper">
|
||||
<button class="action-btn" onclick={() => { showMoveMenu = !showMoveMenu; }} disabled={moving}>
|
||||
{moving ? 'Moving...' : 'Move to...'}
|
||||
@@ -3766,12 +3925,17 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if isOwner}
|
||||
<!-- Share opens a dialog that dispatches access-grant / share-link REST
|
||||
mutations; hide the trigger while peeking. `isOwner && !peeking`
|
||||
(not mutationsEnabled) stays byte-identical for an archived-item
|
||||
owner when not peeking. Any already-open dialog is dismissed by the
|
||||
peeking-begin transition (shareDialogOpen=false). TASK-2172. -->
|
||||
{#if isOwner && !peeking}
|
||||
<button class="action-btn" onclick={() => { shareDialogOpen = true; }}>
|
||||
Share
|
||||
</button>
|
||||
{/if}
|
||||
{#if canEdit}
|
||||
{#if mutationsEnabled}
|
||||
{#if confirmDelete}
|
||||
<span class="delete-confirm">
|
||||
Delete this item?
|
||||
@@ -3868,7 +4032,7 @@
|
||||
{field}
|
||||
value={rawFieldValue}
|
||||
onchange={(v) => updateField(field.key, v)}
|
||||
readonly={!canEdit}
|
||||
readonly={!mutationsEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3883,7 +4047,7 @@
|
||||
{tags}
|
||||
suggestions={tagSuggestions}
|
||||
onchange={updateTags}
|
||||
readonly={!canEdit}
|
||||
readonly={!mutationsEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3896,7 +4060,7 @@
|
||||
<div class="field-row">
|
||||
<span class="field-label">Assigned to</span>
|
||||
<div class="field-value">
|
||||
{#if canEdit}
|
||||
{#if mutationsEnabled}
|
||||
<select
|
||||
class="assignment-select"
|
||||
value={item.assigned_user_id ?? ''}
|
||||
@@ -3929,7 +4093,7 @@
|
||||
<div class="field-row">
|
||||
<span class="field-label">Role</span>
|
||||
<div class="field-value">
|
||||
{#if canEdit}
|
||||
{#if mutationsEnabled}
|
||||
<select
|
||||
class="assignment-select"
|
||||
value={item.agent_role_id ?? ''}
|
||||
@@ -3963,11 +4127,26 @@
|
||||
Editor, EditorBubbleMenu, provider, collabKey and SSE stay
|
||||
persistent across an A→B item switch (the no-{#key} perf premise). -->
|
||||
<div class="content-panel">
|
||||
<!-- Master-freeze (TASK-2172): the Rich⇄Markdown toggle is a
|
||||
provider-LIFECYCLE control — switching to Markdown flushes,
|
||||
sets rawMode, nulls collabKey and DESTROYS the retained
|
||||
provider (violating retain-alive). Hide it entirely while
|
||||
peeking; the onclick guards below are belt-and-suspenders. -->
|
||||
{#if !peeking}
|
||||
<div class="editor-mode-toggle">
|
||||
<button
|
||||
class="mode-btn"
|
||||
class:active={!rawMode}
|
||||
onclick={async () => {
|
||||
// DELIBERATE exception to HT-2176 Option A (TASK-2172): the
|
||||
// Rich⇄Markdown mode flip mints/tears-down the collab provider
|
||||
// (raw mode nulls collabKey → provider.destroy). Option A lets
|
||||
// pre-pane SAVES complete, but a provider-DESTROYING mode change
|
||||
// while peeking would violate retain-alive (D2). So the toggle
|
||||
// is a NEW action blocked here + hidden while peeking; the raw/
|
||||
// collab CONTENT still flushes below — only the mode FLIP is
|
||||
// deferred to un-peek.
|
||||
if (peeking) return;
|
||||
// Flush any pending raw debounce SYNCHRONOUSLY
|
||||
// before the collab provider mints; otherwise
|
||||
// the deferred PATCH fires post-mint and gets
|
||||
@@ -3985,7 +4164,15 @@
|
||||
const startItemId = item?.id;
|
||||
const startGen = loadGeneration;
|
||||
const ok = await flushRawIfPending();
|
||||
if (startGen !== loadGeneration || item?.id !== startItemId) return;
|
||||
// Freeze recheck (TASK-2172 / Option A): the mode toggle is a
|
||||
// NEW UI action that changes the PROVIDER lifecycle (raw⇄rich
|
||||
// mints/tears down the collab provider). It's hidden while
|
||||
// peeking, but a click landed pre-pane can resolve here after
|
||||
// peeking began — the pending raw SAVE (flushRawIfPending) is
|
||||
// allowed to complete, but the mode FLIP is blocked so the
|
||||
// frozen master's provider lifecycle isn't changed. No data
|
||||
// loss (content saved); the flip is just deferred to un-peek.
|
||||
if (startGen !== loadGeneration || item?.id !== startItemId || peeking) return;
|
||||
if (ok) {
|
||||
rawSeedMarkdown = null;
|
||||
rawMode = false;
|
||||
@@ -3997,6 +4184,13 @@
|
||||
class="mode-btn"
|
||||
class:active={rawMode}
|
||||
onclick={async () => {
|
||||
// DELIBERATE exception to Option A (TASK-2172): see the Rich
|
||||
// button above — switching to raw nulls collabKey and DESTROYS
|
||||
// the retained provider, which retain-alive (D2) forbids while
|
||||
// peeking. The collab CONTENT still flushes below (the pre-pane
|
||||
// save completes); only the provider-destroying mode FLIP is
|
||||
// blocked + deferred to un-peek.
|
||||
if (peeking) return;
|
||||
// Toggle rich+collab → raw. We need to
|
||||
// land the live Y.Doc state in
|
||||
// items.content BEFORE activating raw
|
||||
@@ -4049,7 +4243,10 @@
|
||||
// show B a recovery toast, keep looping, or
|
||||
// fall through to rawMode=true for the wrong
|
||||
// item (PLAN-2105 / TASK-2112; coordinator).
|
||||
if (!item || item.id !== itemId || genAtToggle !== loadGeneration) return;
|
||||
// `|| peeking` (TASK-2172 / R14): peeking can begin
|
||||
// mid-loop; bail before the NEXT flush dispatch so a
|
||||
// frozen master issues no further collab PATCH.
|
||||
if (!item || item.id !== itemId || genAtToggle !== loadGeneration || peeking) return;
|
||||
if (result === 'failed' || result === 'skipped') {
|
||||
// 'failed' — PATCH errored;
|
||||
// runCollabFlush already
|
||||
@@ -4112,6 +4309,12 @@
|
||||
if (!item || item.id !== itemId || genAtToggle !== loadGeneration) return;
|
||||
}
|
||||
}
|
||||
// Freeze recheck (TASK-2172): peeking can begin DURING the
|
||||
// awaited flush loop. Switching to rawMode nulls collabKey
|
||||
// and DESTROYS the retained provider — a retain-alive
|
||||
// violation. Bail before the mode flip; the flush already
|
||||
// landed items.content, so nothing is lost.
|
||||
if (peeking) return;
|
||||
// Cancel any pending timer-driven flush
|
||||
// scheduled by edits during the await
|
||||
// window — left armed, it would fire
|
||||
@@ -4124,9 +4327,15 @@
|
||||
title="Raw markdown editor"
|
||||
>Markdown</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if rawMode}
|
||||
{#key item.id}
|
||||
<RawMarkdownEditor content={rawSeedMarkdown ?? item.content ?? ''} onUpdate={handleRawContentUpdate} readonly={!canEdit} />
|
||||
<!-- Master-freeze (TASK-2172, HT-2176 Option A): a peeking
|
||||
master's raw editor is read-only, so NO NEW raw edit can be
|
||||
entered. A save the user debounced BEFORE the pane opened
|
||||
completes normally (it persists the master's own pre-pane
|
||||
content; not suppressed). -->
|
||||
<RawMarkdownEditor content={rawSeedMarkdown ?? item.content ?? ''} onUpdate={handleRawContentUpdate} readonly={!canEdit || peeking} />
|
||||
{/key}
|
||||
{:else}
|
||||
<!--
|
||||
@@ -4184,16 +4393,41 @@
|
||||
<ContentError
|
||||
title="Content unavailable"
|
||||
detail="Could not sync with the server. Reload the editor to try again."
|
||||
onRetry={retryCollabSync}
|
||||
onRetry={peeking ? undefined : retryCollabSync}
|
||||
/>
|
||||
{:else if collabProvider?.state === 'connecting' && !hasEverSynced}
|
||||
<ContentSkeleton variant="inline" />
|
||||
{:else}
|
||||
{#key `${item.id}:true:${forceRefreshNonce}`}
|
||||
<!--
|
||||
Master-freeze (TASK-2154 D2 / TASK-2172): `peeking` is
|
||||
in the {#key} so a freeze/un-freeze REMOUNTS the editor
|
||||
re-bound to the SAME live Y.Doc (ydoc prop unchanged —
|
||||
no teardown of the PROVIDER/Y.Doc, no data loss, the
|
||||
forceRefreshNonce remount pattern). The remount is
|
||||
load-bearing: BlockDragHandle is construction-gated on
|
||||
`editable` (Editor.svelte ~885) — a complex plugin with no
|
||||
single runtime gate — so a reactive editable flip alone
|
||||
would leave the drag-reorder mutation hole open.
|
||||
`editable={!peeking}` also auto-disables the slash menu,
|
||||
mobile toolbar, and table toolbar. Defaults false →
|
||||
editable=true, byte-identical for non-host callers.
|
||||
|
||||
Tradeoff (HT-2176 Option A): the remount destroys the
|
||||
editor VIEW, so an editor-bound action in flight at the
|
||||
instant the pane opens (a paste/drop upload, a rotate/crop
|
||||
transform, a bubble-menu create, a source refresh) is
|
||||
interrupted rather than completed. All degrade GRACEFULLY
|
||||
— the completion paths check `view.isDestroyed`/identity
|
||||
and drop silently; existing content is never lost (it's in
|
||||
the retained Y.Doc). This is the accepted cost of closing
|
||||
the drag-reorder NEW-mutation hole, which the freeze must
|
||||
do; the alternative (no remount) reopens that hole.
|
||||
-->
|
||||
{#key `${item.id}:true:${forceRefreshNonce}:${peeking}`}
|
||||
<Editor
|
||||
content={editorContent}
|
||||
onUpdate={handleContentUpdate}
|
||||
editable={true}
|
||||
editable={!peeking}
|
||||
itemId={item.id}
|
||||
ydoc={ydoc}
|
||||
awareness={collabProvider?.awareness}
|
||||
@@ -4204,7 +4438,9 @@
|
||||
{/key}
|
||||
{/if}
|
||||
{/if}
|
||||
{#if canEdit}
|
||||
{#if mutationsEnabled}
|
||||
<!-- Editor mutation UI — gated on the master-freeze predicate
|
||||
(TASK-2172): a peeking master shows no bubble/link popover. -->
|
||||
<EditorBubbleMenu
|
||||
editor={editorInstance}
|
||||
{wsSlug}
|
||||
@@ -4259,7 +4495,7 @@
|
||||
{#if entry.status}
|
||||
<span class="link-status">{formatFieldDisplay(entry.status)}</span>
|
||||
{/if}
|
||||
{#if entry.linkId && canEdit}
|
||||
{#if entry.linkId && mutationsEnabled}
|
||||
<button class="link-delete-btn" title="Remove relationship" onclick={() => handleDeleteLink(entry.linkId)}>×</button>
|
||||
{/if}
|
||||
</span>
|
||||
@@ -4272,8 +4508,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add Relationship — gated on canEdit (PLAN-1100 / TASK-1105). -->
|
||||
{#if item && canEdit}
|
||||
<!-- Add Relationship — gated on the master-freeze predicate (PLAN-1100 /
|
||||
TASK-1105; frozen while peeking, TASK-2172). -->
|
||||
{#if item && mutationsEnabled}
|
||||
<div class="add-relationship-section">
|
||||
{#if !showAddLink}
|
||||
<button class="add-relationship-btn" onclick={() => { showAddLink = true; }}>
|
||||
@@ -4330,7 +4567,13 @@
|
||||
always-mounted SSE guarantee below. -->
|
||||
{#if item}
|
||||
<div id="item-children" class="children-anchor">
|
||||
<ChildItems {wsSlug} {username} {itemSlug} itemId={item.id} parentFields={fields} terminalStatuses={childTerminalStatuses} onChildrenChange={(children) => { if (keyedSlug !== itemSlug) return; handleChildrenChange(children); }} {canEdit} selfDirty={localDirty} selfLastSaveTime={localLastSaveTime} onOpenTarget={paneOpenTarget} />
|
||||
<!-- ChildItems takes the REAL `canEdit` (reorder authorizes off
|
||||
parent edit) plus a SEPARATE `frozen={peeking}` (TASK-2172): the
|
||||
freeze stops add-child + reorder while child-row navigation stays
|
||||
live, WITHOUT routing add-child's independent capability logic
|
||||
through the parent's canEdit (that would change non-peeking
|
||||
behavior — the byte-identity regression the orchestrator flagged). -->
|
||||
<ChildItems {wsSlug} {username} {itemSlug} itemId={item.id} parentFields={fields} terminalStatuses={childTerminalStatuses} onChildrenChange={(children) => { if (keyedSlug !== itemSlug) return; handleChildrenChange(children); }} {canEdit} frozen={peeking} selfDirty={localDirty} selfLastSaveTime={localLastSaveTime} onOpenTarget={paneOpenTarget} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -4358,6 +4601,10 @@
|
||||
|
||||
<!-- Unified Timeline (comments + activity + versions) -->
|
||||
<div id="item-timeline" class="timeline-section">
|
||||
<!-- Timeline freeze (TASK-2172 / R12): `frozen={peeking}` hides the
|
||||
comment composer, disables reply/reaction/delete, unmounts any
|
||||
already-open comment/reply edit form (and its CommentEditor direct
|
||||
upload), and hides version restore on a peeking master. -->
|
||||
<ItemTimeline
|
||||
{wsSlug}
|
||||
{username}
|
||||
@@ -4367,6 +4614,7 @@
|
||||
onRestore={handleVersionRestore}
|
||||
itemId={item.id}
|
||||
collectionId={item.collection_id}
|
||||
frozen={peeking}
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
// PLAN-2154 Phase 2 / HT-2176 Option A (TASK-2172) — pre-pane field-save probe.
|
||||
//
|
||||
// Mounts the REAL `FieldEditor` to prove the Option A invariant for FIELDS: a
|
||||
// value the user typed BEFORE the pane opened still SAVES (its 500ms debounce
|
||||
// fires onchange even after the field flips read-only on peeking-begin), while
|
||||
// a NEW field edit cannot be started once read-only. `peeking` drives the
|
||||
// FieldEditor's `readonly` exactly as ItemDetail does (`readonly={!mutationsEnabled}`,
|
||||
// with canEdit=true here). A `<button>` flips peeking so the test can simulate
|
||||
// the pane opening mid-edit without prop-rerender gymnastics.
|
||||
import FieldEditor from '$lib/components/fields/FieldEditor.svelte';
|
||||
import type { FieldDef } from '$lib/types';
|
||||
|
||||
let { onchange }: { onchange: (v: any) => void } = $props();
|
||||
|
||||
let peeking = $state(false);
|
||||
const field: FieldDef = { key: 'component', label: 'Component', type: 'text' };
|
||||
</script>
|
||||
|
||||
<button data-testid="begin-peek" onclick={() => (peeking = true)}>peek</button>
|
||||
|
||||
<FieldEditor {field} value="" {onchange} readonly={peeking} />
|
||||
@@ -0,0 +1,101 @@
|
||||
<script lang="ts">
|
||||
// PLAN-2154 Phase 2 / D2 / R12 (TASK-2172) — master-freeze wiring probe.
|
||||
//
|
||||
// HT-2176 Option A: the freeze blocks the INITIATION of NEW edits while the
|
||||
// master peeks; a save the user debounced BEFORE the pane opened completes
|
||||
// normally (no suppression, no data loss, no provider teardown). So this probe
|
||||
// asserts the NEW-EDIT GATES only — every new-edit surface is disabled/gated
|
||||
// while peeking, and byte-identical to the canEdit-only baseline when not.
|
||||
// (That a pre-pane in-flight save is NOT suppressed is a runtime property of
|
||||
// ItemDetail's saver/flush paths — no recheck blocks them — not assertable in
|
||||
// this static gate probe.)
|
||||
//
|
||||
// The running-app assertion is deferred to TASK-2175 (F) — no host passes
|
||||
// `peeking={true}` until TASK-2174 (E). The probe imports the SAME
|
||||
// `computeMutationsEnabled` helper `ItemDetail` uses and renders the CANONICAL
|
||||
// gate EXPRESSIONS from ItemDetail.svelte, so the two can't drift — same
|
||||
// pattern as GuardProbe.svelte for the localDirty shadow.
|
||||
import { computeMutationsEnabled } from '../mutationGate';
|
||||
|
||||
let {
|
||||
canEdit = true,
|
||||
peeking = false,
|
||||
canRestore = true,
|
||||
isOwner = true,
|
||||
quickActionsPresent = false,
|
||||
}: {
|
||||
canEdit?: boolean;
|
||||
peeking?: boolean;
|
||||
canRestore?: boolean;
|
||||
isOwner?: boolean;
|
||||
quickActionsPresent?: boolean;
|
||||
} = $props();
|
||||
|
||||
// The exact derived from ItemDetail.svelte.
|
||||
let mutationsEnabled = $derived(computeMutationsEnabled(canEdit, peeking));
|
||||
</script>
|
||||
|
||||
<!-- Scalar gate props threaded to child components (mirror the exact
|
||||
ItemDetail expressions). -->
|
||||
<div data-testid="mutationsEnabled">{mutationsEnabled}</div>
|
||||
<div data-testid="editor-editable">{!peeking}</div>
|
||||
<div data-testid="raw-readonly">{!canEdit || peeking}</div>
|
||||
<!-- FieldEditor input is readonly while peeking → NO new field edit can be
|
||||
started; a value typed BEFORE the pane opened still saves (updateField has
|
||||
no peeking recheck — its debounce completes). -->
|
||||
<div data-testid="field-readonly">{!mutationsEnabled}</div>
|
||||
<!-- ChildItems receives the REAL canEdit (reorder authorizes off parent edit)
|
||||
plus a SEPARATE frozen — NOT mutationsEnabled — so add-child's independent
|
||||
capability logic is untouched when not peeking. Timeline mirrors this. -->
|
||||
<div data-testid="child-canEdit">{canEdit}</div>
|
||||
<div data-testid="child-frozen">{peeking}</div>
|
||||
<div data-testid="timeline-frozen">{peeking}</div>
|
||||
|
||||
<!-- The Rich⇄Markdown mode toggle is a provider-LIFECYCLE control (switching to
|
||||
Markdown destroys the retained collab provider), so it hides on `!peeking`
|
||||
— NOT on `mutationsEnabled` (a genuine read-only viewer keeps the toggle;
|
||||
only a peeking master must not tear the provider down). -->
|
||||
{#if !peeking}
|
||||
<button data-testid="mode-toggle">Rich / Markdown</button>
|
||||
{/if}
|
||||
|
||||
<!-- Mutation UI gated on `mutationsEnabled` — unmounted while peeking. -->
|
||||
{#if mutationsEnabled}
|
||||
<button data-testid="delete-btn">Delete</button>
|
||||
{/if}
|
||||
{#if mutationsEnabled}
|
||||
<button data-testid="move-btn">Move to…</button>
|
||||
{/if}
|
||||
{#if mutationsEnabled}
|
||||
<button data-testid="add-relationship-btn">+ Add relationship</button>
|
||||
{/if}
|
||||
{#if mutationsEnabled}
|
||||
<button data-testid="editor-mutation-ui">bubble/link popover</button>
|
||||
{/if}
|
||||
|
||||
<!-- Title: editable click-to-edit vs read-only heading. -->
|
||||
{#if mutationsEnabled}
|
||||
<button data-testid="title-editable">Edit title</button>
|
||||
{:else}
|
||||
<h1 data-testid="title-readonly">Title</h1>
|
||||
{/if}
|
||||
|
||||
<!-- Archived restore uses the `canRestore && !peeking` split (canEdit is forced
|
||||
false for archived items, so it can't ride `mutationsEnabled`). -->
|
||||
{#if canRestore && !peeking}
|
||||
<button data-testid="archived-restore-btn">Restore</button>
|
||||
{/if}
|
||||
|
||||
<!-- Star gates on `peeking` ONLY (viewers can star; mutationsEnabled would
|
||||
wrongly disable a non-peeking viewer). -->
|
||||
<button data-testid="star-btn" disabled={peeking}>Star</button>
|
||||
|
||||
<!-- Share + the whole quick-actions menu gate on `!peeking` (the menu unmounts
|
||||
while peeking, dismissing any open dropdown/create-form). NOT mutationsEnabled
|
||||
— byte-identical for an archived-item owner. -->
|
||||
{#if isOwner && !peeking}
|
||||
<button data-testid="share-btn">Share</button>
|
||||
{/if}
|
||||
{#if (quickActionsPresent || isOwner) && !peeking}
|
||||
<button data-testid="quickactions-menu">Quick actions</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import FreezeProbe from './FreezeProbe.svelte';
|
||||
import FieldSaveProbe from './FieldSaveProbe.svelte';
|
||||
|
||||
// PLAN-2154 Phase 2 / D2 / R12 (TASK-2172) — retain-alive master freeze.
|
||||
//
|
||||
// HT-2176 Option A: the freeze blocks the INITIATION of NEW edits while peeking;
|
||||
// a pre-pane in-flight/debounced save completes on its own (not suppressed, not
|
||||
// re-flushed on un-peek). These tests therefore assert the NEW-EDIT GATES only —
|
||||
// (a) every new-edit surface is disabled/gated while peeking, and byte-identical
|
||||
// to the canEdit-only baseline when not. There are deliberately NO suspend /
|
||||
// resume / re-flush assertions: nothing is suspended under Option A.
|
||||
//
|
||||
// FreezeProbe mounts the CANONICAL freeze gate expressions from ItemDetail,
|
||||
// backed by the shared `computeMutationsEnabled` helper, so the gate predicate +
|
||||
// wiring can't drift. The running-app assertion is TASK-2175's — no host passes
|
||||
// `peeking={true}` until TASK-2174.
|
||||
|
||||
function target(): HTMLElement {
|
||||
return document.body.appendChild(document.createElement('div'));
|
||||
}
|
||||
|
||||
function text(root: HTMLElement, testid: string): string {
|
||||
return root.querySelector(`[data-testid="${testid}"]`)?.textContent ?? '';
|
||||
}
|
||||
|
||||
function present(root: HTMLElement, testid: string): boolean {
|
||||
return root.querySelector(`[data-testid="${testid}"]`) != null;
|
||||
}
|
||||
|
||||
function disabled(root: HTMLElement, testid: string): boolean {
|
||||
return (root.querySelector(`[data-testid="${testid}"]`) as HTMLButtonElement | null)?.disabled ?? false;
|
||||
}
|
||||
|
||||
// Every mutation surface the master-freeze gates behind `mutationsEnabled`.
|
||||
const MUTATION_SURFACES = [
|
||||
'delete-btn',
|
||||
'move-btn',
|
||||
'add-relationship-btn',
|
||||
'editor-mutation-ui',
|
||||
'title-editable',
|
||||
];
|
||||
|
||||
describe('retain-alive master freeze wiring (TASK-2172)', () => {
|
||||
let root: HTMLElement | null = null;
|
||||
let instance: ReturnType<typeof mount> | null = null;
|
||||
|
||||
function render(props: { canEdit?: boolean; peeking?: boolean; canRestore?: boolean }) {
|
||||
root = target();
|
||||
instance = mount(FreezeProbe, { target: root, props });
|
||||
flushSync();
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
if (instance) unmount(instance);
|
||||
root?.remove();
|
||||
instance = null;
|
||||
root = null;
|
||||
});
|
||||
|
||||
it('peeking=true freezes EVERY mutation surface even for an editor (canEdit=true)', () => {
|
||||
const r = render({ canEdit: true, peeking: true });
|
||||
|
||||
expect(text(r, 'mutationsEnabled')).toBe('false');
|
||||
// Editor is read-only and the raw editor is read-only; the field input
|
||||
// is read-only too (no NEW field edit can be started while peeking).
|
||||
expect(text(r, 'editor-editable')).toBe('false');
|
||||
expect(text(r, 'raw-readonly')).toBe('true');
|
||||
expect(text(r, 'field-readonly')).toBe('true');
|
||||
// Child + timeline receive the frozen signal (child keeps the REAL
|
||||
// canEdit — the freeze rides the separate `frozen` prop, not canEdit).
|
||||
expect(text(r, 'child-canEdit')).toBe('true');
|
||||
expect(text(r, 'child-frozen')).toBe('true');
|
||||
expect(text(r, 'timeline-frozen')).toBe('true');
|
||||
// Every gated mutation control is unmounted.
|
||||
for (const surface of MUTATION_SURFACES) {
|
||||
expect(present(r, surface), `${surface} must be frozen`).toBe(false);
|
||||
}
|
||||
// Title falls through to the read-only heading, and archived restore hides.
|
||||
expect(present(r, 'title-readonly')).toBe(true);
|
||||
expect(present(r, 'archived-restore-btn')).toBe(false);
|
||||
// The mode toggle (provider-teardown control) is hidden — retain-alive.
|
||||
expect(present(r, 'mode-toggle')).toBe(false);
|
||||
// Star disabled; Share + the whole quick-actions menu hidden/dismissed.
|
||||
expect(disabled(r, 'star-btn')).toBe(true);
|
||||
expect(present(r, 'share-btn')).toBe(false);
|
||||
expect(present(r, 'quickactions-menu')).toBe(false);
|
||||
});
|
||||
|
||||
it('peeking=false, canEdit=true keeps every mutation surface live (byte-identical baseline)', () => {
|
||||
const r = render({ canEdit: true, peeking: false });
|
||||
|
||||
expect(text(r, 'mutationsEnabled')).toBe('true');
|
||||
expect(text(r, 'editor-editable')).toBe('true');
|
||||
expect(text(r, 'raw-readonly')).toBe('false');
|
||||
expect(text(r, 'field-readonly')).toBe('false');
|
||||
expect(text(r, 'child-canEdit')).toBe('true');
|
||||
expect(text(r, 'child-frozen')).toBe('false');
|
||||
expect(text(r, 'timeline-frozen')).toBe('false');
|
||||
for (const surface of MUTATION_SURFACES) {
|
||||
expect(present(r, surface), `${surface} must be live`).toBe(true);
|
||||
}
|
||||
expect(present(r, 'title-readonly')).toBe(false);
|
||||
expect(present(r, 'archived-restore-btn')).toBe(true);
|
||||
// A non-peeking master keeps its mode toggle (editor or viewer alike).
|
||||
expect(present(r, 'mode-toggle')).toBe(true);
|
||||
// Star enabled; Share + quick-actions menu live.
|
||||
expect(disabled(r, 'star-btn')).toBe(false);
|
||||
expect(present(r, 'share-btn')).toBe(true);
|
||||
expect(present(r, 'quickactions-menu')).toBe(true);
|
||||
});
|
||||
|
||||
it('star stays enabled for a non-peeking VIEWER, and share/quick-actions for a non-peeking archived owner (byte-identity)', () => {
|
||||
// A viewer (canEdit=false, mutationsEnabled=false) can still star when not
|
||||
// peeking — star gates on peeking, not mutationsEnabled.
|
||||
let r = render({ canEdit: false, peeking: false, isOwner: false });
|
||||
expect(disabled(r, 'star-btn')).toBe(false);
|
||||
unmount(instance!);
|
||||
root!.remove();
|
||||
|
||||
// An archived-item owner (isOwner=true, canEdit=false) keeps Share + the
|
||||
// quick-actions menu when not peeking — they gate on `!peeking`, not
|
||||
// mutationsEnabled (which would fold in the archived canEdit=false).
|
||||
r = render({ canEdit: false, peeking: false, isOwner: true });
|
||||
expect(present(r, 'share-btn')).toBe(true);
|
||||
expect(present(r, 'quickactions-menu')).toBe(true);
|
||||
|
||||
// Peeking freezes both regardless.
|
||||
unmount(instance!);
|
||||
root!.remove();
|
||||
r = render({ canEdit: false, peeking: true, isOwner: true });
|
||||
expect(disabled(r, 'star-btn')).toBe(true);
|
||||
expect(present(r, 'share-btn')).toBe(false);
|
||||
expect(present(r, 'quickactions-menu')).toBe(false);
|
||||
});
|
||||
|
||||
it('peeking gate is independent of canEdit — a view-only master already freezes without peeking', () => {
|
||||
// canEdit=false alone (a genuine read-only viewer) hides the mutation UI;
|
||||
// `mutationsEnabled` collapses to canEdit when not peeking, so the freeze
|
||||
// prop changes nothing for that caller.
|
||||
const r = render({ canEdit: false, peeking: false });
|
||||
expect(text(r, 'mutationsEnabled')).toBe('false');
|
||||
expect(text(r, 'editor-editable')).toBe('true'); // still a live (read-only) editor, NOT peeking
|
||||
// The mode toggle stays for a read-only viewer — it's peeking-gated, not
|
||||
// mutation-gated (the provider only needs protecting from a peek teardown).
|
||||
expect(present(r, 'mode-toggle')).toBe(true);
|
||||
for (const surface of MUTATION_SURFACES) {
|
||||
expect(present(r, surface)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('archived restore rides `canRestore && !peeking`, not canEdit (archived items force canEdit false)', () => {
|
||||
// Not peeking: restore shows for a permitted user.
|
||||
let r = render({ canEdit: false, peeking: false, canRestore: true });
|
||||
expect(present(r, 'archived-restore-btn')).toBe(true);
|
||||
unmount(instance!);
|
||||
root!.remove();
|
||||
|
||||
// Peeking: restore hides even though canRestore is true.
|
||||
r = render({ canEdit: false, peeking: true, canRestore: true });
|
||||
expect(present(r, 'archived-restore-btn')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// HT-2176 Option A / fix #1 (TASK-2172): a FIELD value typed BEFORE the pane
|
||||
// opened must SAVE. FieldEditor debounces onchange ~500ms; if peeking begins
|
||||
// before it fires, the field flips read-only (blocking any NEW edit) but the
|
||||
// pending debounce still fires onchange — and `updateField` no longer rechecks
|
||||
// `mutationsEnabled`, so the pre-pane value completes. This mounts the REAL
|
||||
// FieldEditor to lock that behavior in.
|
||||
describe('pre-pane debounced field save completes under Option A (TASK-2172)', () => {
|
||||
let root: HTMLElement | null = null;
|
||||
let instance: ReturnType<typeof mount> | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
if (instance) unmount(instance);
|
||||
root?.remove();
|
||||
instance = null;
|
||||
root = null;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function mountProbe(onchange: (v: any) => void) {
|
||||
root = target();
|
||||
instance = mount(FieldSaveProbe, { target: root, props: { onchange } });
|
||||
flushSync();
|
||||
return root;
|
||||
}
|
||||
|
||||
it('a value typed before peeking still fires onchange after the field goes read-only', () => {
|
||||
vi.useFakeTimers();
|
||||
const onchange = vi.fn();
|
||||
const r = mountProbe(onchange);
|
||||
|
||||
// Type a value (pre-pane) — arms the 500ms debounce, does NOT fire yet.
|
||||
const input = r.querySelector<HTMLInputElement>('input.field-input')!;
|
||||
input.value = 'ui/editor';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
flushSync();
|
||||
expect(onchange).not.toHaveBeenCalled();
|
||||
|
||||
// Pane opens mid-edit → the field flips read-only (input unmounts, no new
|
||||
// edit possible) but the armed debounce survives.
|
||||
r.querySelector<HTMLButtonElement>('[data-testid="begin-peek"]')!.click();
|
||||
flushSync();
|
||||
expect(r.querySelector('input.field-input')).toBeNull(); // NEW edit blocked
|
||||
|
||||
// The debounce fires → the pre-pane value reaches the parent (→ updateField,
|
||||
// which no longer suppresses it). The freeze did NOT drop the typed value.
|
||||
vi.advanceTimersByTime(500);
|
||||
expect(onchange).toHaveBeenCalledTimes(1);
|
||||
expect(onchange).toHaveBeenCalledWith('ui/editor');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { computeMutationsEnabled } from './mutationGate';
|
||||
|
||||
// PLAN-2154 Phase 2 / D2 / R12 (TASK-2172). `computeMutationsEnabled` is the
|
||||
// single source of truth for the retain-alive master freeze: `ItemDetail`'s
|
||||
// `mutationsEnabled` derived and its freeze test probe both import it, so the
|
||||
// gate predicate can't drift between the component and its coverage.
|
||||
describe('computeMutationsEnabled — master-freeze gate predicate (TASK-2172)', () => {
|
||||
it('is exactly canEdit while NOT peeking (byte-identical for non-host callers)', () => {
|
||||
// `peeking` defaults false at every non-host call site, so the freeze
|
||||
// prop must leave the gate equal to raw `canEdit`.
|
||||
expect(computeMutationsEnabled(true, false)).toBe(true);
|
||||
expect(computeMutationsEnabled(false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('is false while peeking regardless of canEdit (complete freeze)', () => {
|
||||
// The core freeze invariant: a peeking master enables NO mutation,
|
||||
// even for an owner/editor who would otherwise have full edit rights.
|
||||
expect(computeMutationsEnabled(true, true)).toBe(false);
|
||||
expect(computeMutationsEnabled(false, true)).toBe(false);
|
||||
});
|
||||
|
||||
it('only ever returns true in the single (canEdit && !peeking) corner', () => {
|
||||
const truthTable: Array<[boolean, boolean, boolean]> = [
|
||||
[true, false, true],
|
||||
[true, true, false],
|
||||
[false, false, false],
|
||||
[false, true, false],
|
||||
];
|
||||
for (const [canEdit, peeking, expected] of truthTable) {
|
||||
expect(computeMutationsEnabled(canEdit, peeking)).toBe(expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
// PLAN-2154 Phase 2 / D2 (TASK-2172): the master-freeze gate predicate.
|
||||
//
|
||||
// The full-page item view (`[slug]/+page.svelte`) keeps its `ItemDetail`
|
||||
// master ALIVE while a detail pane peeks beside it (retain-alive — the collab
|
||||
// provider is never torn down; no flush, snapshot, or persistence barrier).
|
||||
// The master goes read-only via a single `peeking` prop. `mutationsEnabled`
|
||||
// is the derived flag that gates EVERY local/user-originated mutation surface
|
||||
// on the master: title edit, fields, assignment, move, delete, relationship
|
||||
// add/remove, ChildItems add-child/reorder, the raw-markdown editor, the
|
||||
// editor bubble/link popovers, the timeline (comment compose + edit forms +
|
||||
// version restore), and the AttachmentImage node toolbar (via `editable`).
|
||||
//
|
||||
// Factored out as a pure function so the gate is a single, unit-testable
|
||||
// source of truth shared by `ItemDetail.svelte` and its freeze test probe —
|
||||
// the two can't drift. Keep it dependency-free.
|
||||
//
|
||||
// `peeking` defaults falsy at every non-peeking call site, so
|
||||
// `mutationsEnabled === canEdit` for every existing (non-host) caller — the
|
||||
// prop is a pure addition and leaves those callers byte-identical.
|
||||
export function computeMutationsEnabled(canEdit: boolean, peeking: boolean): boolean {
|
||||
return canEdit && !peeking;
|
||||
}
|
||||
@@ -36,16 +36,29 @@
|
||||
*/
|
||||
itemId?: string;
|
||||
collectionId?: string;
|
||||
/**
|
||||
* PLAN-2154 Phase 2 / D2 / R12 (TASK-2172): master-freeze. When the
|
||||
* full-page host peeks a detail pane beside this item's ItemDetail, the
|
||||
* master passes `frozen={true}` so its timeline goes fully read-only:
|
||||
* the composer hides, reply/reaction/delete disable, any already-open
|
||||
* comment/reply edit form (and its CommentEditor direct-upload) unmounts,
|
||||
* and version restore hides. Defaults false → byte-identical for every
|
||||
* existing caller.
|
||||
*/
|
||||
frozen?: boolean;
|
||||
}
|
||||
|
||||
let { wsSlug, username = '', itemSlug, currentContent, items = [], onRestore, itemId, collectionId }: Props = $props();
|
||||
let { wsSlug, username = '', itemSlug, currentContent, items = [], onRestore, itemId, collectionId, frozen = false }: Props = $props();
|
||||
|
||||
// Resolve canEditItem reactively; falls to false if itemId/collectionId
|
||||
// aren't supplied (e.g. an older caller).
|
||||
// aren't supplied (e.g. an older caller). Folds in the master-freeze gate
|
||||
// (TASK-2172): while `frozen`, the composer / reply / reaction / delete
|
||||
// affordances all disable through this single derived.
|
||||
let canEdit = $derived(
|
||||
itemId && collectionId
|
||||
!frozen &&
|
||||
(itemId && collectionId
|
||||
? workspaceStore.canEditItem({ id: itemId, collection_id: collectionId })
|
||||
: false
|
||||
: false)
|
||||
);
|
||||
|
||||
let entries: TimelineEntry[] = $state([]);
|
||||
@@ -415,9 +428,14 @@
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- Comment compose — gated on canEditItem (PLAN-1100 / TASK-1107).
|
||||
<!-- Comment compose — gated on canEditItem (PLAN-1100 / TASK-1107) folded
|
||||
with the master-freeze (`frozen` → canEdit=false, TASK-2172).
|
||||
Read-only viewers / guests with view-only grants see the timeline
|
||||
thread but cannot post; the composer is hidden entirely. -->
|
||||
thread but cannot post; the composer is hidden entirely. Note: an
|
||||
attachment upload the user STARTED in this composer before the pane
|
||||
opened is orphaned when the composer unmounts on freeze — the ACCEPTED,
|
||||
tracked BUG-2177 tradeoff (its upload bails via attachment-upload.ts's
|
||||
view.isDestroyed check; no crash, no committed-content loss). -->
|
||||
{#if canEdit}
|
||||
<div class="compose">
|
||||
<CommentEditor
|
||||
@@ -459,6 +477,7 @@
|
||||
{items}
|
||||
{currentUserId}
|
||||
{canEdit}
|
||||
{frozen}
|
||||
{isAdmin}
|
||||
{attachmentResolver}
|
||||
onDelete={handleDelete}
|
||||
@@ -476,6 +495,7 @@
|
||||
{itemSlug}
|
||||
{currentContent}
|
||||
{onRestore}
|
||||
{frozen}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,15 @@
|
||||
* don't pass it.
|
||||
*/
|
||||
canEdit?: boolean;
|
||||
/**
|
||||
* PLAN-2154 Phase 2 / D2 / R12 (TASK-2172): master-freeze. When a peeking
|
||||
* master's timeline is frozen, the author/admin edit affordance
|
||||
* (`canEditComment`) is suppressed AND any ALREADY-OPEN comment/reply edit
|
||||
* form (with its CommentEditor direct-upload) is unmounted — distinct from
|
||||
* `canEdit`, which gates reply/reaction/delete. Defaults false →
|
||||
* byte-identical for existing callers.
|
||||
*/
|
||||
frozen?: boolean;
|
||||
/**
|
||||
* Resolves `pad-attachment:UUID` references in the comment / reply
|
||||
* bodies to inline images or file chips (IDEA-1650). Optional —
|
||||
@@ -37,7 +46,7 @@
|
||||
onRemoveReaction: (commentId: string, emoji: string) => void;
|
||||
}
|
||||
|
||||
let { comment, wsSlug, username = '', items, currentUserId = '', canEdit = true, attachmentResolver, isAdmin = false, onDelete, onReply, onEdit, onReaction, onRemoveReaction }: Props = $props();
|
||||
let { comment, wsSlug, username = '', items, currentUserId = '', canEdit = true, frozen = false, attachmentResolver, isAdmin = false, onDelete, onReply, onEdit, onReaction, onRemoveReaction }: Props = $props();
|
||||
|
||||
let showReplyForm = $state(false);
|
||||
let submittingReply = $state(false);
|
||||
@@ -52,6 +61,8 @@
|
||||
// A null/empty user_id has no provable author → admin-only, matching the
|
||||
// server's canEditComment.
|
||||
function canEditComment(c: Comment): boolean {
|
||||
// Master-freeze (TASK-2172): a peeking master surfaces no edit affordance.
|
||||
if (frozen) return false;
|
||||
return isAdmin || (!!c.user_id && c.user_id === currentUserId);
|
||||
}
|
||||
|
||||
@@ -204,7 +215,9 @@
|
||||
<div class="activity-label">commented on update</div>
|
||||
{/if}
|
||||
|
||||
{#if editing}
|
||||
{#if editing && !frozen}
|
||||
<!-- Master-freeze (TASK-2172 / R12): unmount an already-open edit form —
|
||||
and its CommentEditor direct-upload — the instant the master peeks. -->
|
||||
<div class="edit-compose">
|
||||
<CommentEditor
|
||||
{wsSlug}
|
||||
@@ -307,7 +320,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if editingReplyId === reply.id}
|
||||
{#if editingReplyId === reply.id && !frozen}
|
||||
<!-- Master-freeze (TASK-2172 / R12): same unmount-on-peek as
|
||||
the comment edit form above. -->
|
||||
<div class="edit-compose">
|
||||
<CommentEditor
|
||||
{wsSlug}
|
||||
|
||||
@@ -10,9 +10,16 @@
|
||||
itemSlug: string;
|
||||
currentContent: string;
|
||||
onRestore?: (item: Item) => void;
|
||||
/**
|
||||
* PLAN-2154 Phase 2 / D2 / R12 (TASK-2172): master-freeze. The restore
|
||||
* button is otherwise ungated (server-authorized), so a peeking master
|
||||
* must hide it to keep the freeze complete. Defaults false →
|
||||
* byte-identical for existing callers.
|
||||
*/
|
||||
frozen?: boolean;
|
||||
}
|
||||
|
||||
let { version, wsSlug, itemSlug, currentContent, onRestore }: Props = $props();
|
||||
let { version, wsSlug, itemSlug, currentContent, onRestore, frozen = false }: Props = $props();
|
||||
|
||||
let expanded = $state(false);
|
||||
let confirming = $state(false);
|
||||
@@ -68,6 +75,9 @@
|
||||
}
|
||||
|
||||
async function confirmRestore() {
|
||||
// Master-freeze guard (TASK-2172): the restore UI is hidden while frozen,
|
||||
// but drop a straggler click so a peeking master never dispatches restore.
|
||||
if (frozen) return;
|
||||
// Capture the item identity before the await so a mid-flight item switch
|
||||
// (rapid j/k / row-click in the split pane) can't fire onRestore with
|
||||
// A's restored item into a parent now showing B — nor flip this card's
|
||||
@@ -140,6 +150,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Master-freeze (TASK-2172 / R12): a peeking master hides restore. -->
|
||||
{#if !frozen}
|
||||
<div class="restore-area">
|
||||
{#if confirming}
|
||||
<div class="confirm-prompt">
|
||||
@@ -173,6 +185,7 @@
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user