@gmickel/gno 2.5.1 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -5
- package/assets/skill/README.md +5 -1
- package/assets/skill/SKILL.md +80 -4
- package/assets/skill/cli-reference.md +132 -2
- package/assets/skill/examples.md +30 -0
- package/assets/skill/mcp-reference.md +54 -1
- package/assets/skill/recipes/capture-and-file.md +6 -0
- package/assets/skill/recipes/memory-file-decision.md +6 -0
- package/assets/skill/recipes/memory-supersede-fact.md +5 -0
- package/assets/skill/recipes/session-evidence-lookup.md +98 -0
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.5.1.zip → gno-browser-clipper-v2.7.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.7.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/cli.md +449 -35
- package/spec/mcp.md +234 -4
- package/spec/output-schemas/ask.schema.json +1 -1
- package/spec/output-schemas/capture-receipt.schema.json +4 -1
- package/spec/output-schemas/doctor.schema.json +88 -0
- package/spec/output-schemas/error.schema.json +11 -2
- package/spec/output-schemas/get.schema.json +1 -1
- package/spec/output-schemas/mcp-capture-result.schema.json +4 -2
- package/spec/output-schemas/memory-remember.schema.json +10 -4
- package/spec/output-schemas/multi-get.schema.json +4 -1
- package/spec/output-schemas/peek.schema.json +2 -9
- package/spec/output-schemas/request-status.schema.json +113 -0
- package/spec/output-schemas/resident-status.schema.json +22 -0
- package/spec/output-schemas/search-result.schema.json +1 -1
- package/spec/output-schemas/search-results.schema.json +1 -1
- package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
- package/spec/output-schemas/sessions-discovery.schema.json +38 -0
- package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
- package/spec/output-schemas/sessions-status.schema.json +432 -0
- package/spec/output-schemas/status.schema.json +98 -0
- package/src/cli/commands/ask.ts +14 -2
- package/src/cli/commands/capture.ts +55 -96
- package/src/cli/commands/daemon.ts +41 -0
- package/src/cli/commands/doctor.ts +54 -20
- package/src/cli/commands/embed.ts +41 -3
- package/src/cli/commands/ls.ts +3 -0
- package/src/cli/commands/memory.ts +12 -3
- package/src/cli/commands/query.ts +5 -0
- package/src/cli/commands/request-status.ts +59 -0
- package/src/cli/commands/reset.ts +39 -5
- package/src/cli/commands/sessions.ts +713 -0
- package/src/cli/commands/shared.ts +14 -1
- package/src/cli/commands/status.ts +63 -5
- package/src/cli/commands/vec.ts +54 -0
- package/src/cli/detach.ts +29 -1
- package/src/cli/errors.ts +13 -9
- package/src/cli/program.ts +441 -2
- package/src/cli/session-binding.ts +49 -0
- package/src/config/types.ts +8 -0
- package/src/core/capture-publish.ts +239 -0
- package/src/core/capture-sync.ts +12 -2
- package/src/core/host-paths.ts +31 -0
- package/src/core/memory-remember.ts +234 -122
- package/src/core/memory-types.ts +11 -0
- package/src/core/network-boundary-inventory.ts +8 -0
- package/src/core/request-receipts.ts +671 -0
- package/src/core/shutdown-budget.ts +6 -0
- package/src/core/vector-partition-status.ts +52 -0
- package/src/embed/backlog.ts +124 -18
- package/src/embed/fingerprint.ts +6 -3
- package/src/embed/retry.ts +66 -27
- package/src/embed/variant-backlog.ts +15 -10
- package/src/embed/variant-retry.ts +31 -22
- package/src/index.ts +30 -2
- package/src/llm/native-worker/dispatcher.ts +2 -0
- package/src/llm/native-worker/embedding-identity.ts +42 -0
- package/src/llm/native-worker/protocol.ts +1 -0
- package/src/llm/types.ts +3 -0
- package/src/mcp/context.ts +17 -0
- package/src/mcp/http-egress.ts +4 -0
- package/src/mcp/http-transport.ts +2 -0
- package/src/mcp/resources/index.ts +6 -5
- package/src/mcp/tool-descriptions-core.ts +1 -1
- package/src/mcp/tools/capture.ts +87 -85
- package/src/mcp/tools/index.ts +77 -4
- package/src/mcp/tools/memory-remember.ts +8 -1
- package/src/mcp/tools/memory-shared.ts +7 -1
- package/src/mcp/tools/request-status.ts +73 -0
- package/src/mcp/tools/sessions.ts +208 -0
- package/src/mcp/tools/status.ts +4 -0
- package/src/pipeline/hybrid.ts +37 -7
- package/src/pipeline/vsearch.ts +14 -2
- package/src/sdk/client.ts +180 -84
- package/src/sdk/index.ts +6 -0
- package/src/sdk/types.ts +54 -2
- package/src/serve/capture-service.ts +98 -32
- package/src/serve/config-sync.ts +3 -2
- package/src/serve/embed-scheduler.ts +133 -19
- package/src/serve/host-path-redaction.ts +79 -0
- package/src/serve/public/app.tsx +4 -1
- package/src/serve/public/components/CaptureModal.tsx +26 -8
- package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
- package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
- package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
- package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
- package/src/serve/public/components/sessions/api.ts +40 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/hooks/use-api.ts +26 -3
- package/src/serve/public/lib/request-intent.ts +77 -0
- package/src/serve/public/lib/snippet.tsx +52 -0
- package/src/serve/public/lib/workspace-actions.ts +12 -1
- package/src/serve/public/lib/workspace-tabs.ts +2 -0
- package/src/serve/public/pages/Dashboard.tsx +22 -9
- package/src/serve/public/pages/DocView.tsx +25 -6
- package/src/serve/public/pages/DocumentEditor.tsx +224 -104
- package/src/serve/public/pages/Search.tsx +1 -41
- package/src/serve/public/pages/Sessions.tsx +350 -0
- package/src/serve/resident-runtime.ts +69 -4
- package/src/serve/resident-status.ts +13 -1
- package/src/serve/routes/api.ts +476 -147
- package/src/serve/routes/sessions.ts +766 -0
- package/src/serve/security.ts +9 -0
- package/src/serve/server.ts +215 -10
- package/src/serve/session-automation.ts +146 -0
- package/src/serve/status-model.ts +16 -0
- package/src/serve/status.ts +2 -0
- package/src/serve/watch-reconciliation-shared.ts +3 -0
- package/src/serve/watch-service-events.ts +3 -2
- package/src/serve/watch-service-run-flush.ts +35 -2
- package/src/serve/watch-service.ts +5 -0
- package/src/sessions/archive.ts +348 -0
- package/src/sessions/automation-state.ts +444 -0
- package/src/sessions/automation-status.ts +239 -0
- package/src/sessions/automation.ts +1169 -0
- package/src/sessions/binding.ts +105 -0
- package/src/sessions/claude-hook.ts +240 -0
- package/src/sessions/config.ts +176 -0
- package/src/sessions/format.ts +191 -0
- package/src/sessions/import-child-env.ts +8 -0
- package/src/sessions/import-child.ts +152 -0
- package/src/sessions/parsers/claude-code.ts +259 -0
- package/src/sessions/parsers/codex.ts +303 -0
- package/src/sessions/parsers/hermes.ts +248 -0
- package/src/sessions/parsers/openclaw.ts +496 -0
- package/src/sessions/parsers/shared.ts +184 -0
- package/src/sessions/sanitize.ts +222 -0
- package/src/sessions/service.ts +1533 -0
- package/src/sessions/setup.ts +477 -0
- package/src/sessions/sources.ts +518 -0
- package/src/sessions/state.ts +118 -0
- package/src/sessions/types.ts +457 -0
- package/src/store/migrations/031-runtime-independent-vectors.ts +29 -0
- package/src/store/migrations/032-vector-runtime-callers.ts +17 -0
- package/src/store/migrations/index.ts +4 -0
- package/src/store/sqlite/adapter.ts +76 -16
- package/src/store/sqlite/scoped-index.ts +9 -0
- package/src/store/types.ts +11 -1
- package/src/store/vector/lazy.ts +46 -43
- package/src/store/vector/runtime-compat.ts +651 -0
- package/src/store/vector/sqlite-vec.ts +20 -2
- package/src/store/vector/status.ts +276 -35
- package/src/store/vector/types.ts +2 -0
- package/src/store/vector/variant-search.ts +71 -23
- package/src/store/vector/variants.ts +49 -14
- package/browser-extension/artifacts/gno-browser-clipper-v2.5.1.zip.sha256 +0 -1
|
@@ -73,6 +73,13 @@ import {
|
|
|
73
73
|
loadLatestLocalHistory,
|
|
74
74
|
type LocalHistoryEntry,
|
|
75
75
|
} from "../lib/local-history";
|
|
76
|
+
import {
|
|
77
|
+
clearRequestIntent,
|
|
78
|
+
type RequestIntent,
|
|
79
|
+
requestIdForIntent,
|
|
80
|
+
} from "../lib/request-intent";
|
|
81
|
+
|
|
82
|
+
const SAVE_INTENT_KEY = "gno:save-intent";
|
|
76
83
|
import { getActiveWikiLinkQuery } from "../lib/wiki-link";
|
|
77
84
|
|
|
78
85
|
interface PageProps {
|
|
@@ -123,6 +130,8 @@ interface UpdateDocResponse {
|
|
|
123
130
|
sourceHash: string;
|
|
124
131
|
modifiedAt?: string;
|
|
125
132
|
};
|
|
133
|
+
/** Present when the save carried a request ID. */
|
|
134
|
+
request?: { replayed: boolean };
|
|
126
135
|
}
|
|
127
136
|
|
|
128
137
|
interface DocsAutocompleteResponse {
|
|
@@ -132,7 +141,7 @@ interface DocsAutocompleteResponse {
|
|
|
132
141
|
type SaveStatus = "saved" | "saving" | "unsaved" | "error";
|
|
133
142
|
|
|
134
143
|
function useDebouncedCallback<T extends unknown[]>(
|
|
135
|
-
callback: (...args: T) =>
|
|
144
|
+
callback: (...args: T) => unknown,
|
|
136
145
|
delay: number
|
|
137
146
|
) {
|
|
138
147
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -198,8 +207,12 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
198
207
|
const [saveError, setSaveError] = useState<string | null>(null);
|
|
199
208
|
const [creatingCopy, setCreatingCopy] = useState(false);
|
|
200
209
|
const [copyError, setCopyError] = useState<string | null>(null);
|
|
201
|
-
|
|
202
|
-
|
|
210
|
+
/**
|
|
211
|
+
* `outside`: the file changed under the editor. `unconfirmed`: a save's
|
|
212
|
+
* response was lost, so a change on disk may be that save's own commit.
|
|
213
|
+
*/
|
|
214
|
+
const [changeNotice, setChangeNotice] = useState<
|
|
215
|
+
"outside" | "unconfirmed" | null
|
|
203
216
|
>(null);
|
|
204
217
|
const [historyEntries, setHistoryEntries] = useState<LocalHistoryEntry[]>([]);
|
|
205
218
|
const [historyDialogOpen, setHistoryDialogOpen] = useState(false);
|
|
@@ -309,23 +322,58 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
309
322
|
}
|
|
310
323
|
}, [syncScroll]);
|
|
311
324
|
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
325
|
+
// Autosave timers and Cmd+S run later than the render that scheduled them:
|
|
326
|
+
// read the current revision and last committed content from refs.
|
|
327
|
+
const docRef = useRef(doc);
|
|
328
|
+
const committedContentRef = useRef(originalContent);
|
|
329
|
+
const draftRef = useRef(content);
|
|
330
|
+
useEffect(() => {
|
|
331
|
+
docRef.current = doc;
|
|
332
|
+
}, [doc]);
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
draftRef.current = content;
|
|
335
|
+
}, [content]);
|
|
336
|
+
// One request ID per (document revision, content) save intent: retrying a
|
|
337
|
+
// save whose response was lost replays it instead of reporting a conflict.
|
|
338
|
+
const saveIntentRef = useRef<RequestIntent | null>(null);
|
|
339
|
+
// Content of a save that is in flight or whose response was lost: its
|
|
340
|
+
// commit is unknown, so change events are held until a response says whose
|
|
341
|
+
// change they were. Retry save re-sends exactly this content.
|
|
342
|
+
const unknownSaveRef = useRef<string | null>(null);
|
|
343
|
+
const changedWhileUnknownRef = useRef(false);
|
|
344
|
+
|
|
345
|
+
/** Save `contentToSave`; resolves true once it is committed on disk. */
|
|
346
|
+
const persistContent = useCallback(
|
|
347
|
+
async (contentToSave: string): Promise<boolean> => {
|
|
348
|
+
const current = docRef.current;
|
|
349
|
+
if (!current) return false;
|
|
350
|
+
if (contentToSave === committedContentRef.current) {
|
|
351
|
+
setSaveStatus("saved");
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
316
354
|
|
|
317
355
|
setSaveStatus("saving");
|
|
318
356
|
setSaveError(null);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
357
|
+
unknownSaveRef.current = contentToSave;
|
|
358
|
+
|
|
359
|
+
const {
|
|
360
|
+
data,
|
|
361
|
+
error: err,
|
|
362
|
+
outcomeUnknown,
|
|
363
|
+
} = await apiFetch<UpdateDocResponse>(
|
|
364
|
+
`/api/docs/${encodeURIComponent(current.docid)}`,
|
|
322
365
|
{
|
|
323
366
|
method: "PUT",
|
|
324
367
|
body: JSON.stringify({
|
|
325
368
|
content: contentToSave,
|
|
326
|
-
expectedSourceHash:
|
|
327
|
-
expectedModifiedAt:
|
|
328
|
-
uri:
|
|
369
|
+
expectedSourceHash: current.source.sourceHash,
|
|
370
|
+
expectedModifiedAt: current.source.modifiedAt,
|
|
371
|
+
uri: current.uri,
|
|
372
|
+
requestId: requestIdForIntent(
|
|
373
|
+
saveIntentRef,
|
|
374
|
+
`${current.uri}\u0000${current.source.sourceHash}\u0000${contentToSave}`,
|
|
375
|
+
SAVE_INTENT_KEY
|
|
376
|
+
),
|
|
329
377
|
}),
|
|
330
378
|
}
|
|
331
379
|
);
|
|
@@ -333,32 +381,71 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
333
381
|
if (err) {
|
|
334
382
|
setSaveStatus("error");
|
|
335
383
|
setSaveError(err);
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
384
|
+
if (outcomeUnknown) {
|
|
385
|
+
// Never downgrade a warning about a change from elsewhere.
|
|
386
|
+
setChangeNotice((notice) =>
|
|
387
|
+
notice === "outside" ? notice : "unconfirmed"
|
|
388
|
+
);
|
|
389
|
+
return false;
|
|
341
390
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if (
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
...currentDoc.source,
|
|
352
|
-
sourceHash: data.version.sourceHash,
|
|
353
|
-
modifiedAt: data.version.modifiedAt,
|
|
354
|
-
},
|
|
355
|
-
}
|
|
356
|
-
: currentDoc
|
|
391
|
+
// A definitive rejection: this save did not commit, so a change seen
|
|
392
|
+
// meanwhile came from elsewhere.
|
|
393
|
+
unknownSaveRef.current = null;
|
|
394
|
+
if (changedWhileUnknownRef.current) {
|
|
395
|
+
changedWhileUnknownRef.current = false;
|
|
396
|
+
setChangeNotice("outside");
|
|
397
|
+
} else {
|
|
398
|
+
setChangeNotice((notice) =>
|
|
399
|
+
notice === "unconfirmed" ? null : notice
|
|
357
400
|
);
|
|
358
401
|
}
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
unknownSaveRef.current = null;
|
|
406
|
+
changedWhileUnknownRef.current = false;
|
|
407
|
+
if (data?.request?.replayed) {
|
|
408
|
+
// A replay reports an earlier commit; disk may have moved on since.
|
|
409
|
+
// Only a read showing a different hash is evidence of another writer.
|
|
410
|
+
const { data: latest } = await apiFetch<DocData>(
|
|
411
|
+
`/api/doc?uri=${encodeURIComponent(current.uri)}`
|
|
412
|
+
);
|
|
413
|
+
setChangeNotice(
|
|
414
|
+
latest && latest.source.sourceHash !== data.version.sourceHash
|
|
415
|
+
? "outside"
|
|
416
|
+
: null
|
|
417
|
+
);
|
|
418
|
+
} else {
|
|
419
|
+
// Written now against the loaded revision: the next change event is
|
|
420
|
+
// this save's own sync, and any earlier notice is stale.
|
|
421
|
+
ignoreDocEventsUntilRef.current = Date.now() + 5_000;
|
|
422
|
+
setChangeNotice(null);
|
|
359
423
|
}
|
|
424
|
+
const previous = committedContentRef.current;
|
|
425
|
+
if (previous !== contentToSave) {
|
|
426
|
+
appendLocalHistory(current.docid, previous);
|
|
427
|
+
refreshHistoryEntries(current.docid);
|
|
428
|
+
}
|
|
429
|
+
clearRequestIntent(saveIntentRef, SAVE_INTENT_KEY);
|
|
430
|
+
committedContentRef.current = contentToSave;
|
|
431
|
+
setSaveStatus("saved");
|
|
432
|
+
setOriginalContent(contentToSave);
|
|
433
|
+
setLastSaved(new Date());
|
|
434
|
+
if (data) {
|
|
435
|
+
const next = {
|
|
436
|
+
...current,
|
|
437
|
+
source: {
|
|
438
|
+
...current.source,
|
|
439
|
+
sourceHash: data.version.sourceHash,
|
|
440
|
+
modifiedAt: data.version.modifiedAt,
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
docRef.current = next;
|
|
444
|
+
setDoc(next);
|
|
445
|
+
}
|
|
446
|
+
return true;
|
|
360
447
|
},
|
|
361
|
-
[
|
|
448
|
+
[]
|
|
362
449
|
);
|
|
363
450
|
|
|
364
451
|
const handleCreateEditableCopy = useCallback(async () => {
|
|
@@ -476,16 +563,18 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
476
563
|
);
|
|
477
564
|
|
|
478
565
|
// Debounced auto-save
|
|
479
|
-
const { debouncedFn: debouncedSave } =
|
|
480
|
-
|
|
481
|
-
2000
|
|
482
|
-
);
|
|
566
|
+
const { debouncedFn: debouncedSave, cancel: cancelAutosave } =
|
|
567
|
+
useDebouncedCallback(persistContent, 2000);
|
|
483
568
|
|
|
484
569
|
// Handle content changes
|
|
485
570
|
const handleContentChange = useCallback(
|
|
486
571
|
(newContent: string) => {
|
|
487
572
|
setContent(newContent);
|
|
488
|
-
if (newContent
|
|
573
|
+
if (newContent === originalContent) {
|
|
574
|
+
// Back to the committed text: drop a pending autosave of a draft.
|
|
575
|
+
cancelAutosave();
|
|
576
|
+
setSaveStatus("saved");
|
|
577
|
+
} else {
|
|
489
578
|
setSaveStatus("unsaved");
|
|
490
579
|
debouncedSave(newContent);
|
|
491
580
|
}
|
|
@@ -509,7 +598,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
509
598
|
setWikiLinkOpen(true);
|
|
510
599
|
setWikiLinkActiveIndex(0);
|
|
511
600
|
},
|
|
512
|
-
[originalContent, debouncedSave]
|
|
601
|
+
[cancelAutosave, originalContent, debouncedSave]
|
|
513
602
|
);
|
|
514
603
|
|
|
515
604
|
useEffect(() => {
|
|
@@ -530,55 +619,27 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
530
619
|
});
|
|
531
620
|
}, [doc?.collection, wikiLinkOpen, wikiLinkQuery]);
|
|
532
621
|
|
|
533
|
-
// Force save (Cmd+S) -
|
|
622
|
+
// Force save (Cmd+S) - supersedes a pending autosave, then triggers embedding
|
|
534
623
|
const handleForceSave = useCallback(async () => {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
// Save document
|
|
541
|
-
const { data, error: err } = await apiFetch<UpdateDocResponse>(
|
|
542
|
-
`/api/docs/${encodeURIComponent(doc.docid)}`,
|
|
543
|
-
{
|
|
544
|
-
method: "PUT",
|
|
545
|
-
body: JSON.stringify({
|
|
546
|
-
content,
|
|
547
|
-
expectedSourceHash: doc.source.sourceHash,
|
|
548
|
-
expectedModifiedAt: doc.source.modifiedAt,
|
|
549
|
-
uri: doc.uri,
|
|
550
|
-
}),
|
|
551
|
-
}
|
|
552
|
-
);
|
|
553
|
-
|
|
554
|
-
if (err) {
|
|
555
|
-
setSaveStatus("error");
|
|
556
|
-
setSaveError(err);
|
|
557
|
-
return;
|
|
624
|
+
cancelAutosave();
|
|
625
|
+
if (!hasUnsavedChanges) return;
|
|
626
|
+
if (await persistContent(content)) {
|
|
627
|
+
// Fire and forget - don't block on result
|
|
628
|
+
void apiFetch("/api/embed", { method: "POST" });
|
|
558
629
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
630
|
+
}, [cancelAutosave, content, hasUnsavedChanges, persistContent]);
|
|
631
|
+
|
|
632
|
+
// Retry the unconfirmed save itself (same content, same request ID), even
|
|
633
|
+
// when the draft has since changed or returned to the loaded text; once it
|
|
634
|
+
// is confirmed, the draft as it is then is saved on top of it.
|
|
635
|
+
const retryUnconfirmedSave = useCallback(async () => {
|
|
636
|
+
const pending = unknownSaveRef.current;
|
|
637
|
+
if (pending === null) return;
|
|
638
|
+
cancelAutosave();
|
|
639
|
+
if (await persistContent(pending)) {
|
|
640
|
+
await persistContent(draftRef.current);
|
|
564
641
|
}
|
|
565
|
-
|
|
566
|
-
setOriginalContent(content);
|
|
567
|
-
setLastSaved(new Date());
|
|
568
|
-
if (data) {
|
|
569
|
-
setDoc({
|
|
570
|
-
...doc,
|
|
571
|
-
source: {
|
|
572
|
-
...doc.source,
|
|
573
|
-
sourceHash: data.version.sourceHash,
|
|
574
|
-
modifiedAt: data.version.modifiedAt,
|
|
575
|
-
},
|
|
576
|
-
});
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
// Trigger embedding (fire and forget - don't block on result)
|
|
580
|
-
void apiFetch("/api/embed", { method: "POST" });
|
|
581
|
-
}, [hasUnsavedChanges, doc, content]);
|
|
642
|
+
}, [cancelAutosave, persistContent]);
|
|
582
643
|
|
|
583
644
|
const loadDocument = useCallback(() => {
|
|
584
645
|
const uri = currentTarget.uri;
|
|
@@ -599,6 +660,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
599
660
|
const docContent = data.content ?? "";
|
|
600
661
|
setContent(docContent);
|
|
601
662
|
setOriginalContent(docContent);
|
|
663
|
+
committedContentRef.current = docContent;
|
|
602
664
|
refreshHistoryEntries(data.docid);
|
|
603
665
|
// Ensure CodeMirror reflects content after async load
|
|
604
666
|
requestAnimationFrame(() => {
|
|
@@ -617,20 +679,30 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
617
679
|
loadDocument();
|
|
618
680
|
}, [loadDocument]);
|
|
619
681
|
|
|
682
|
+
// Judge each change event once; later doc updates must not re-raise it.
|
|
683
|
+
const handledDocEventRef = useRef<string | null>(null);
|
|
620
684
|
useEffect(() => {
|
|
621
685
|
if (!doc || latestDocEvent?.uri !== doc.uri) {
|
|
622
686
|
return;
|
|
623
687
|
}
|
|
688
|
+
if (handledDocEventRef.current === latestDocEvent.changedAt) {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
handledDocEventRef.current = latestDocEvent.changedAt;
|
|
692
|
+
if (unknownSaveRef.current !== null) {
|
|
693
|
+
changedWhileUnknownRef.current = true;
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
624
696
|
if (Date.now() < ignoreDocEventsUntilRef.current) {
|
|
625
697
|
return;
|
|
626
698
|
}
|
|
627
|
-
|
|
628
|
-
"This document changed on disk. Reload before continuing."
|
|
629
|
-
);
|
|
699
|
+
setChangeNotice("outside");
|
|
630
700
|
}, [doc, latestDocEvent?.changedAt, latestDocEvent?.uri]);
|
|
631
701
|
|
|
632
702
|
const reloadDocument = useCallback(() => {
|
|
633
|
-
|
|
703
|
+
unknownSaveRef.current = null;
|
|
704
|
+
changedWhileUnknownRef.current = false;
|
|
705
|
+
setChangeNotice(null);
|
|
634
706
|
loadDocument();
|
|
635
707
|
}, [loadDocument]);
|
|
636
708
|
|
|
@@ -752,7 +824,12 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
752
824
|
|
|
753
825
|
// Save and navigate
|
|
754
826
|
const handleSaveAndNavigate = async () => {
|
|
755
|
-
|
|
827
|
+
cancelAutosave();
|
|
828
|
+
// A failed save keeps the editor (and its error) instead of losing the draft.
|
|
829
|
+
if (!(await persistContent(content))) {
|
|
830
|
+
setShowUnsavedDialog(false);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
756
833
|
// Trigger embedding (fire and forget)
|
|
757
834
|
void apiFetch("/api/embed", { method: "POST" });
|
|
758
835
|
setShowUnsavedDialog(false);
|
|
@@ -793,9 +870,13 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
793
870
|
<TooltipProvider>
|
|
794
871
|
<Tooltip>
|
|
795
872
|
<TooltipTrigger asChild>
|
|
796
|
-
<div
|
|
797
|
-
|
|
798
|
-
|
|
873
|
+
<div
|
|
874
|
+
aria-label={text}
|
|
875
|
+
className="flex min-w-0 max-w-[16rem] shrink items-center gap-1.5 text-sm"
|
|
876
|
+
role="status"
|
|
877
|
+
>
|
|
878
|
+
<Icon className={`size-4 shrink-0 ${className}`} />
|
|
879
|
+
<span className="hidden truncate text-muted-foreground sm:inline">
|
|
799
880
|
{saveStatus === "saved" && lastSaved
|
|
800
881
|
? formatTime(lastSaved)
|
|
801
882
|
: text}
|
|
@@ -907,7 +988,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
907
988
|
<div className="flex h-screen flex-col overflow-hidden">
|
|
908
989
|
{/* Toolbar */}
|
|
909
990
|
<header className="glass shrink-0 border-border/50 border-b">
|
|
910
|
-
<div className="flex items-center gap-
|
|
991
|
+
<div className="flex items-center gap-2 px-2 py-2 sm:gap-3 sm:px-4">
|
|
911
992
|
{/* Home button - Scholarly Dusk brass accent */}
|
|
912
993
|
<Button
|
|
913
994
|
aria-label="Go to dashboard"
|
|
@@ -954,7 +1035,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
954
1035
|
<Separator className="h-5" orientation="vertical" />
|
|
955
1036
|
|
|
956
1037
|
{/* Document info */}
|
|
957
|
-
<div className="flex min-w-0 flex-1 items-center gap-2">
|
|
1038
|
+
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
|
958
1039
|
<PenIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
959
1040
|
<h1 className="truncate font-medium">{doc.title || doc.relPath}</h1>
|
|
960
1041
|
{currentTarget.lineStart && (
|
|
@@ -968,7 +1049,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
968
1049
|
)}
|
|
969
1050
|
{hasUnsavedChanges && (
|
|
970
1051
|
<Badge
|
|
971
|
-
className="shrink-0 bg-yellow-500/20 text-yellow-500"
|
|
1052
|
+
className="hidden shrink-0 bg-yellow-500/20 text-yellow-500 sm:inline-flex"
|
|
972
1053
|
variant="outline"
|
|
973
1054
|
>
|
|
974
1055
|
Unsaved
|
|
@@ -982,6 +1063,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
982
1063
|
<Separator className="h-5" orientation="vertical" />
|
|
983
1064
|
|
|
984
1065
|
<Button
|
|
1066
|
+
className="hidden sm:inline-flex"
|
|
985
1067
|
onClick={() => {
|
|
986
1068
|
void navigator.clipboard.writeText(
|
|
987
1069
|
`${window.location.origin}${buildEditDeepLink({
|
|
@@ -999,7 +1081,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
999
1081
|
|
|
1000
1082
|
{/* Preview toggle */}
|
|
1001
1083
|
<Select onValueChange={setInsertPresetId} value={insertPresetId}>
|
|
1002
|
-
<SelectTrigger className="w-[180px]">
|
|
1084
|
+
<SelectTrigger className="hidden w-[180px] md:flex">
|
|
1003
1085
|
<SelectValue placeholder="Preset" />
|
|
1004
1086
|
</SelectTrigger>
|
|
1005
1087
|
<SelectContent>
|
|
@@ -1012,7 +1094,12 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
1012
1094
|
)}
|
|
1013
1095
|
</SelectContent>
|
|
1014
1096
|
</Select>
|
|
1015
|
-
<Button
|
|
1097
|
+
<Button
|
|
1098
|
+
className="hidden md:inline-flex"
|
|
1099
|
+
onClick={handleInsertPreset}
|
|
1100
|
+
size="sm"
|
|
1101
|
+
variant="outline"
|
|
1102
|
+
>
|
|
1016
1103
|
Insert preset
|
|
1017
1104
|
</Button>
|
|
1018
1105
|
|
|
@@ -1047,7 +1134,7 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
1047
1134
|
aria-label={
|
|
1048
1135
|
syncScroll ? "Disable scroll sync" : "Enable scroll sync"
|
|
1049
1136
|
}
|
|
1050
|
-
className="transition-all duration-200"
|
|
1137
|
+
className="hidden transition-all duration-200 sm:inline-flex"
|
|
1051
1138
|
onClick={() => setSyncScroll(!syncScroll)}
|
|
1052
1139
|
size="sm"
|
|
1053
1140
|
variant={syncScroll ? "secondary" : "ghost"}
|
|
@@ -1070,15 +1157,18 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
1070
1157
|
|
|
1071
1158
|
{/* Save button */}
|
|
1072
1159
|
<Button
|
|
1160
|
+
aria-label="History"
|
|
1161
|
+
className="shrink-0"
|
|
1073
1162
|
disabled={!hasLocalSnapshot}
|
|
1074
1163
|
onClick={() => setHistoryDialogOpen(true)}
|
|
1075
1164
|
size="sm"
|
|
1076
1165
|
variant="outline"
|
|
1077
1166
|
>
|
|
1078
|
-
<HistoryIcon className="mr-1.5
|
|
1079
|
-
History
|
|
1167
|
+
<HistoryIcon className="size-4 sm:mr-1.5" />
|
|
1168
|
+
<span className="hidden sm:inline">History</span>
|
|
1080
1169
|
</Button>
|
|
1081
1170
|
<Button
|
|
1171
|
+
className="shrink-0"
|
|
1082
1172
|
disabled={!hasUnsavedChanges || saveStatus === "saving"}
|
|
1083
1173
|
onClick={handleForceSave}
|
|
1084
1174
|
size="sm"
|
|
@@ -1093,10 +1183,40 @@ export default function DocumentEditor({ navigate }: PageProps) {
|
|
|
1093
1183
|
</div>
|
|
1094
1184
|
</header>
|
|
1095
1185
|
|
|
1096
|
-
{
|
|
1186
|
+
{saveStatus === "error" && saveError && (
|
|
1187
|
+
<p
|
|
1188
|
+
className="border-destructive/30 border-b bg-destructive/10 px-4 py-2 text-destructive text-sm sm:hidden"
|
|
1189
|
+
role="alert"
|
|
1190
|
+
>
|
|
1191
|
+
{saveError}
|
|
1192
|
+
</p>
|
|
1193
|
+
)}
|
|
1194
|
+
|
|
1195
|
+
{changeNotice === "unconfirmed" && (
|
|
1196
|
+
<div className="border-amber-500/30 border-b bg-amber-500/10 px-4 py-3">
|
|
1197
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
1198
|
+
<p className="text-amber-500 text-sm">
|
|
1199
|
+
The save may have completed, but its response was lost. Retry to
|
|
1200
|
+
confirm it.
|
|
1201
|
+
</p>
|
|
1202
|
+
<Button
|
|
1203
|
+
disabled={saveStatus === "saving"}
|
|
1204
|
+
onClick={retryUnconfirmedSave}
|
|
1205
|
+
size="sm"
|
|
1206
|
+
variant="outline"
|
|
1207
|
+
>
|
|
1208
|
+
Retry save
|
|
1209
|
+
</Button>
|
|
1210
|
+
</div>
|
|
1211
|
+
</div>
|
|
1212
|
+
)}
|
|
1213
|
+
|
|
1214
|
+
{changeNotice === "outside" && (
|
|
1097
1215
|
<div className="border-amber-500/30 border-b bg-amber-500/10 px-4 py-3">
|
|
1098
1216
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
1099
|
-
<p className="text-amber-500 text-sm">
|
|
1217
|
+
<p className="text-amber-500 text-sm">
|
|
1218
|
+
This document changed on disk. Reload before continuing.
|
|
1219
|
+
</p>
|
|
1100
1220
|
<Button onClick={reloadDocument} size="sm" variant="outline">
|
|
1101
1221
|
Reload
|
|
1102
1222
|
</Button>
|
|
@@ -50,50 +50,10 @@ import {
|
|
|
50
50
|
fetchServerCapabilities,
|
|
51
51
|
type ServerCapabilities,
|
|
52
52
|
} from "../lib/server-capabilities";
|
|
53
|
+
import { renderSnippet } from "../lib/snippet";
|
|
53
54
|
import { cn } from "../lib/utils";
|
|
54
55
|
import { AIModelSelector, TagFacets } from "./search-page-widgets";
|
|
55
56
|
|
|
56
|
-
/**
|
|
57
|
-
* Render snippet with <mark> tags as highlighted spans.
|
|
58
|
-
* Only allows mark tags - strips all other HTML for safety.
|
|
59
|
-
*/
|
|
60
|
-
function renderSnippet(snippet: string): React.ReactNode {
|
|
61
|
-
const parts: React.ReactNode[] = [];
|
|
62
|
-
let remaining = snippet;
|
|
63
|
-
let key = 0;
|
|
64
|
-
|
|
65
|
-
while (remaining.length > 0) {
|
|
66
|
-
const markStart = remaining.indexOf("<mark>");
|
|
67
|
-
if (markStart === -1) {
|
|
68
|
-
parts.push(remaining);
|
|
69
|
-
break;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
if (markStart > 0) {
|
|
73
|
-
parts.push(remaining.slice(0, markStart));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const markEnd = remaining.indexOf("</mark>", markStart);
|
|
77
|
-
if (markEnd === -1) {
|
|
78
|
-
parts.push(remaining.slice(markStart));
|
|
79
|
-
break;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const highlighted = remaining.slice(markStart + 6, markEnd);
|
|
83
|
-
parts.push(
|
|
84
|
-
<mark
|
|
85
|
-
className="rounded bg-primary/20 px-0.5 font-medium text-primary"
|
|
86
|
-
key={key++}
|
|
87
|
-
>
|
|
88
|
-
{highlighted}
|
|
89
|
-
</mark>
|
|
90
|
-
);
|
|
91
|
-
remaining = remaining.slice(markEnd + 7);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return parts;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
57
|
interface PageProps {
|
|
98
58
|
navigate: (to: string | number) => void;
|
|
99
59
|
}
|