summaryrefslogtreecommitdiff
path: root/makima/frontend/src/routes/files.tsx
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2025-12-24 05:45:22 +0000
committersoryu <soryu@soryu.co>2025-12-24 05:45:22 +0000
commit2faba0388f93d8e4fb86219eba7883b331d501ff (patch)
tree92b83b8d558a652d3777627b2ac95ded250faa48 /makima/frontend/src/routes/files.tsx
parent8f016a0e9d14badc39dffd67ed6fb862f9d08496 (diff)
downloadsoryu-2faba0388f93d8e4fb86219eba7883b331d501ff.tar.gz
soryu-2faba0388f93d8e4fb86219eba7883b331d501ff.zip
Add versioning to files
Diffstat (limited to 'makima/frontend/src/routes/files.tsx')
-rw-r--r--makima/frontend/src/routes/files.tsx207
1 files changed, 171 insertions, 36 deletions
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx
index f398041..0d870f7 100644
--- a/makima/frontend/src/routes/files.tsx
+++ b/makima/frontend/src/routes/files.tsx
@@ -7,6 +7,7 @@ import { CliInput } from "../components/files/CliInput";
import { ConflictNotification } from "../components/files/ConflictNotification";
import { UpdateNotification } from "../components/files/UpdateNotification";
import { useFiles } from "../hooks/useFiles";
+import { useVersionHistory } from "../hooks/useVersionHistory";
import {
useFileSubscription,
type FileUpdateEvent,
@@ -22,37 +23,121 @@ export default function FilesPage() {
const [creating, setCreating] = useState(false);
const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null);
const [remoteFileData, setRemoteFileData] = useState<FileDetailType | null>(null);
- const [hasLocalChanges, setHasLocalChanges] = useState(false);
- const [isActivelyEditing, setIsActivelyEditing] = useState(false);
const pendingUpdateRef = useRef(false);
+ // Track the last version we sent to detect our own updates
+ const lastSentVersionRef = useRef<number | null>(null);
+ // Track the version we just successfully saved (to ignore its WebSocket notification)
+ const lastSavedVersionRef = useRef<number | null>(null);
+ // Use refs for values checked in WebSocket callback to avoid stale closures
+ const hasLocalChangesRef = useRef(false);
+ const isActivelyEditingRef = useRef(false);
+ const currentVersionRef = useRef<number | null>(null);
+
+ // Helper functions to update refs (used only in callbacks, not for rendering)
+ const updateHasLocalChanges = useCallback((value: boolean) => {
+ hasLocalChangesRef.current = value;
+ }, []);
+
+ const updateIsActivelyEditing = useCallback((value: boolean) => {
+ isActivelyEditingRef.current = value;
+ }, []);
+
+ // Version history
+ const {
+ versions,
+ loading: versionsLoading,
+ selectedVersion,
+ loadingVersion,
+ restoring,
+ fetchVersion,
+ restoreToVersion,
+ clearSelectedVersion,
+ fetchVersions,
+ } = useVersionHistory({
+ fileId: id || null,
+ currentVersion: fileDetail?.version || 0,
+ });
+
+ // Handle version restore
+ const handleRestoreVersion = useCallback(
+ async (targetVersion: number) => {
+ const result = await restoreToVersion(targetVersion);
+ if (result) {
+ currentVersionRef.current = result.version;
+ setFileDetail(result);
+ updateHasLocalChanges(false);
+ // Refresh version list after restore
+ fetchVersions();
+ }
+ },
+ [restoreToVersion, fetchVersions, updateHasLocalChanges]
+ );
// Load file detail when URL has an id
useEffect(() => {
if (id) {
setDetailLoading(true);
- setHasLocalChanges(false);
+ updateHasLocalChanges(false);
+ // Reset pending update tracking when switching files
+ pendingUpdateRef.current = false;
+ lastSentVersionRef.current = null;
+ lastSavedVersionRef.current = null;
+ currentVersionRef.current = null;
+ setRemoteUpdate(null);
+ setRemoteFileData(null);
fetchFile(id).then((detail) => {
+ if (detail) {
+ currentVersionRef.current = detail.version;
+ }
setFileDetail(detail);
setDetailLoading(false);
});
} else {
setFileDetail(null);
- setHasLocalChanges(false);
+ currentVersionRef.current = null;
+ updateHasLocalChanges(false);
}
- }, [id, fetchFile]);
+ }, [id, fetchFile, updateHasLocalChanges]);
// Handle file update events from WebSocket
const handleFileUpdate = useCallback(
async (event: FileUpdateEvent) => {
- // Ignore our own updates
+ // Check if this is a version we just saved - ignore it
+ // This handles the case where the WebSocket arrives after the HTTP response
+ if (lastSavedVersionRef.current !== null && event.version === lastSavedVersionRef.current) {
+ lastSavedVersionRef.current = null;
+ return;
+ }
+
+ // If we have a pending update, check if this is our own update
if (pendingUpdateRef.current) {
- pendingUpdateRef.current = false;
+ if (lastSentVersionRef.current !== null) {
+ const expectedNewVersion = lastSentVersionRef.current + 1;
+ if (event.version === expectedNewVersion) {
+ // This is our own update - ignore it
+ pendingUpdateRef.current = false;
+ lastSentVersionRef.current = null;
+ return;
+ }
+ }
+ // We sent an update but received a different version - could be a race condition
+ // Still ignore since we have an update in flight
+ return;
+ }
+
+ // Check if this version matches what we already have
+ // This catches cases where our save's WebSocket arrives late
+ if (currentVersionRef.current !== null && event.version === currentVersionRef.current) {
return;
}
// If no local changes and not actively editing, auto-refresh
- if (!hasLocalChanges && !isActivelyEditing) {
+ // Use refs to get current values (avoid stale closure issues)
+ if (!hasLocalChangesRef.current && !isActivelyEditingRef.current) {
const detail = await fetchFile(event.fileId);
+ if (detail) {
+ currentVersionRef.current = detail.version;
+ }
setFileDetail(detail);
} else {
// Fetch remote version for diff display
@@ -62,7 +147,7 @@ export default function FilesPage() {
setRemoteUpdate(event);
}
},
- [hasLocalChanges, isActivelyEditing, fetchFile]
+ [fetchFile]
);
// Subscribe to file updates
@@ -98,13 +183,22 @@ export default function FilesPage() {
async (fileId: string, name: string, description: string) => {
if (!fileDetail) return;
pendingUpdateRef.current = true;
- const result = await editFile(fileId, { name, description, version: fileDetail.version });
- if (result) {
- setFileDetail(result);
- setHasLocalChanges(false);
+ lastSentVersionRef.current = fileDetail.version;
+ try {
+ const result = await editFile(fileId, { name, description, version: fileDetail.version });
+ if (result) {
+ // Track the saved version to ignore its WebSocket notification
+ lastSavedVersionRef.current = result.version;
+ currentVersionRef.current = result.version;
+ setFileDetail(result);
+ updateHasLocalChanges(false);
+ }
+ } finally {
+ pendingUpdateRef.current = false;
+ lastSentVersionRef.current = null;
}
},
- [editFile, fileDetail]
+ [editFile, fileDetail, updateHasLocalChanges]
);
const handleBodyUpdate = useCallback(
@@ -132,18 +226,27 @@ export default function FilesPage() {
...fileDetail,
body: newBody,
});
- setHasLocalChanges(true);
+ updateHasLocalChanges(true);
// Save to backend with version for optimistic locking
pendingUpdateRef.current = true;
- const result = await editFile(id, { body: newBody, version: fileDetail.version });
- if (result) {
- setFileDetail(result);
- setHasLocalChanges(false);
+ lastSentVersionRef.current = fileDetail.version;
+ try {
+ const result = await editFile(id, { body: newBody, version: fileDetail.version });
+ if (result) {
+ // Track the saved version to ignore its WebSocket notification
+ lastSavedVersionRef.current = result.version;
+ currentVersionRef.current = result.version;
+ setFileDetail(result);
+ updateHasLocalChanges(false);
+ }
+ } finally {
+ pendingUpdateRef.current = false;
+ lastSentVersionRef.current = null;
}
}
},
- [fileDetail, id, editFile]
+ [fileDetail, id, editFile, updateHasLocalChanges]
);
const handleBodyReorder = useCallback(
@@ -159,18 +262,27 @@ export default function FilesPage() {
...fileDetail,
body: newBody,
});
- setHasLocalChanges(true);
+ updateHasLocalChanges(true);
// Save to backend with version for optimistic locking
pendingUpdateRef.current = true;
- const result = await editFile(id, { body: newBody, version: fileDetail.version });
- if (result) {
- setFileDetail(result);
- setHasLocalChanges(false);
+ lastSentVersionRef.current = fileDetail.version;
+ try {
+ const result = await editFile(id, { body: newBody, version: fileDetail.version });
+ if (result) {
+ // Track the saved version to ignore its WebSocket notification
+ lastSavedVersionRef.current = result.version;
+ currentVersionRef.current = result.version;
+ setFileDetail(result);
+ updateHasLocalChanges(false);
+ }
+ } finally {
+ pendingUpdateRef.current = false;
+ lastSentVersionRef.current = null;
}
}
},
- [fileDetail, id, editFile]
+ [fileDetail, id, editFile, updateHasLocalChanges]
);
const handleCreate = useCallback(async () => {
@@ -194,10 +306,13 @@ export default function FilesPage() {
if (id) {
clearConflict();
const detail = await fetchFile(id);
+ if (detail) {
+ currentVersionRef.current = detail.version;
+ }
setFileDetail(detail);
- setHasLocalChanges(false);
+ updateHasLocalChanges(false);
}
- }, [id, clearConflict, fetchFile]);
+ }, [id, clearConflict, fetchFile, updateHasLocalChanges]);
const handleConflictForceOverwrite = useCallback(async () => {
if (id && fileDetail) {
@@ -207,25 +322,37 @@ export default function FilesPage() {
if (latest) {
// Retry with latest version
pendingUpdateRef.current = true;
- const result = await editFile(id, { body: fileDetail.body, version: latest.version });
- if (result) {
- setFileDetail(result);
- setHasLocalChanges(false);
+ lastSentVersionRef.current = latest.version;
+ try {
+ const result = await editFile(id, { body: fileDetail.body, version: latest.version });
+ if (result) {
+ // Track the saved version to ignore its WebSocket notification
+ lastSavedVersionRef.current = result.version;
+ currentVersionRef.current = result.version;
+ setFileDetail(result);
+ updateHasLocalChanges(false);
+ }
+ } finally {
+ pendingUpdateRef.current = false;
+ lastSentVersionRef.current = null;
}
}
}
- }, [id, fileDetail, clearConflict, fetchFile, editFile]);
+ }, [id, fileDetail, clearConflict, fetchFile, editFile, updateHasLocalChanges]);
// Remote update handlers
const handleRemoteUpdateRefresh = useCallback(async () => {
if (id) {
const detail = await fetchFile(id);
+ if (detail) {
+ currentVersionRef.current = detail.version;
+ }
setFileDetail(detail);
setRemoteUpdate(null);
setRemoteFileData(null);
- setHasLocalChanges(false);
+ updateHasLocalChanges(false);
}
- }, [id, fetchFile]);
+ }, [id, fetchFile, updateHasLocalChanges]);
const handleRemoteUpdateDismiss = useCallback(() => {
setRemoteUpdate(null);
@@ -254,9 +381,17 @@ export default function FilesPage() {
onDelete={handleDelete}
onBodyElementUpdate={handleBodyElementUpdate}
onBodyReorder={handleBodyReorder}
- onEditingChange={setIsActivelyEditing}
+ onEditingChange={updateIsActivelyEditing}
hasPendingRemoteUpdate={!!remoteUpdate}
onOverwrite={handleRemoteUpdateDismiss}
+ versions={versions}
+ versionsLoading={versionsLoading}
+ selectedVersion={selectedVersion}
+ loadingVersion={loadingVersion}
+ restoring={restoring}
+ onSelectVersion={fetchVersion}
+ onRestoreVersion={handleRestoreVersion}
+ onClearVersionSelection={clearSelectedVersion}
/>
</div>
<div className="shrink-0">