constellation-analyzer/src/stores/workspaceStore.ts
Jan-Henrik Bruhn 94c7845ca7 refactor: replace redundant window.confirm with custom dialog
Removes duplicate confirmation dialogs when deleting documents.
Previously, users were asked twice: once by the UI component's custom
dialog and again by window.confirm in the store.

Changes:
- Remove window.confirm from workspaceStore deleteDocument function
- Add useConfirm hook to DocumentTabs component for delete action
- Consistent confirmation UX across DocumentManager and DocumentTabs

Both components now show the same styled confirmation dialog with
proper handling of unsaved changes warnings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-11 22:08:00 +02:00

891 lines
27 KiB
TypeScript

import { create } from 'zustand';
import type { ConstellationDocument } from './persistence/types';
import type { Workspace, WorkspaceActions, DocumentMetadata, WorkspaceSettings } from './workspace/types';
import { createDocument as createDocumentHelper, serializeActors, serializeRelations } from './persistence/saver';
import { selectFileForImport, exportDocumentToFile } from './persistence/fileIO';
import {
generateWorkspaceId,
generateDocumentId,
saveWorkspaceState,
loadWorkspaceState,
saveDocumentToStorage,
loadDocumentFromStorage,
deleteDocumentFromStorage,
saveDocumentMetadata,
loadDocumentMetadata,
loadAllDocumentMetadata,
clearWorkspaceStorage,
} from './workspace/persistence';
import { migrateToWorkspace, needsMigration } from './workspace/migration';
import {
exportAllDocumentsAsZip,
exportWorkspace as exportWorkspaceToZip,
selectWorkspaceZipForImport,
} from './workspace/workspaceIO';
import { useToastStore } from './toastStore';
import { useTimelineStore } from './timelineStore';
import { useGraphStore } from './graphStore';
import type { ConstellationState, Timeline } from '../types/timeline';
import { getCurrentGraphFromDocument } from './persistence/loader';
/**
* Workspace Store
*
* Manages multiple documents, tabs, and workspace-level settings
*/
// Default workspace settings
const defaultSettings: WorkspaceSettings = {
maxOpenDocuments: 10,
autoSaveEnabled: true,
defaultNodeTypes: [
{ id: 'person', label: 'Person', color: '#3b82f6', icon: 'Person', description: 'Individual person' },
{ id: 'organization', label: 'Organization', color: '#10b981', icon: 'Business', description: 'Company or group' },
{ id: 'system', label: 'System', color: '#f59e0b', icon: 'Computer', description: 'Technical system' },
{ id: 'concept', label: 'Concept', color: '#8b5cf6', icon: 'Lightbulb', description: 'Abstract concept' },
],
defaultEdgeTypes: [
{ id: 'collaborates', label: 'Collaborates', color: '#3b82f6', style: 'solid' },
{ id: 'reports-to', label: 'Reports To', color: '#10b981', style: 'solid' },
{ id: 'depends-on', label: 'Depends On', color: '#f59e0b', style: 'dashed' },
{ id: 'influences', label: 'Influences', color: '#8b5cf6', style: 'dotted' },
],
recentFiles: [],
};
// Initialize workspace
function initializeWorkspace(): Workspace {
// Check if migration is needed
if (needsMigration()) {
console.log('Migration needed, migrating legacy data...');
const migratedState = migrateToWorkspace();
if (migratedState) {
// Load migrated document
const docId = migratedState.activeDocumentId!;
const doc = loadDocumentFromStorage(docId);
const meta = loadDocumentMetadata(docId);
return {
...migratedState,
documents: doc ? new Map([[docId, doc]]) : new Map(),
documentMetadata: meta ? new Map([[docId, meta]]) : new Map(),
};
}
}
// Try to load existing workspace
const savedState = loadWorkspaceState();
if (savedState) {
// Load all document metadata
const metadata = loadAllDocumentMetadata();
// Load active document if exists
const documents = new Map<string, ConstellationDocument>();
if (savedState.activeDocumentId) {
const doc = loadDocumentFromStorage(savedState.activeDocumentId);
if (doc) {
documents.set(savedState.activeDocumentId, doc);
// Load timeline if it exists
if (doc.timeline) {
useTimelineStore.getState().loadTimeline(savedState.activeDocumentId, doc.timeline as unknown as Timeline);
}
}
}
return {
...savedState,
documents,
documentMetadata: metadata,
};
}
// Create new workspace with no documents (start with empty state)
const workspaceId = generateWorkspaceId();
// Save initial state
const initialState = {
workspaceId,
workspaceName: 'My Workspace',
documentOrder: [],
activeDocumentId: null,
settings: defaultSettings,
};
saveWorkspaceState(initialState);
return {
...initialState,
documents: new Map(),
documentMetadata: new Map(),
};
}
export const useWorkspaceStore = create<Workspace & WorkspaceActions>((set, get) => ({
...initializeWorkspace(),
// Create new document
createDocument: (title = 'Untitled Analysis') => {
const state = get();
const documentId = generateDocumentId();
const now = new Date().toISOString();
// Create copies of the default types using spread to avoid any circular references from store
const nodeTypes = state.settings.defaultNodeTypes.map(nt => ({ ...nt }));
const edgeTypes = state.settings.defaultEdgeTypes.map(et => ({ ...et }));
const newDoc = createDocumentHelper(
[],
[],
nodeTypes,
edgeTypes
);
newDoc.metadata.documentId = documentId;
newDoc.metadata.title = title;
const metadata: DocumentMetadata = {
id: documentId,
title,
isDirty: false,
lastModified: now,
};
// Save document
saveDocumentToStorage(documentId, newDoc);
saveDocumentMetadata(documentId, metadata);
// Load the timeline from the newly created document into timelineStore
useTimelineStore.getState().loadTimeline(documentId, newDoc.timeline as unknown as Timeline);
// Update workspace
set((state) => {
const newDocuments = new Map(state.documents);
newDocuments.set(documentId, newDoc);
const newMetadata = new Map(state.documentMetadata);
newMetadata.set(documentId, metadata);
const newOrder = [...state.documentOrder, documentId];
const newState = {
documents: newDocuments,
documentMetadata: newMetadata,
documentOrder: newOrder,
activeDocumentId: documentId,
};
// Save workspace state
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: newOrder,
activeDocumentId: documentId,
settings: state.settings,
});
return newState;
});
useToastStore.getState().showToast(`Document "${title}" created`, 'success');
return documentId;
},
// Create new document from existing document's types (template)
createDocumentFromTemplate: (sourceDocumentId: string, title = 'Untitled Analysis') => {
const state = get();
const sourceDoc = state.documents.get(sourceDocumentId);
if (!sourceDoc) {
console.error(`Source document ${sourceDocumentId} not found`);
return '';
}
const documentId = generateDocumentId();
const now = new Date().toISOString();
// Get node and edge types from source document's current graph
const sourceGraph = getCurrentGraphFromDocument(sourceDoc);
if (!sourceGraph) {
console.error('Failed to get graph from source document');
return '';
}
// Create new document with the same node and edge types, but no actors/relations
const newDoc = createDocumentHelper(
[],
[],
sourceGraph.nodeTypes,
sourceGraph.edgeTypes
);
newDoc.metadata.documentId = documentId;
newDoc.metadata.title = title;
const metadata: DocumentMetadata = {
id: documentId,
title,
isDirty: false,
lastModified: now,
};
// Save document
saveDocumentToStorage(documentId, newDoc);
saveDocumentMetadata(documentId, metadata);
// Load the timeline from the newly created document into timelineStore
useTimelineStore.getState().loadTimeline(documentId, newDoc.timeline as unknown as Timeline);
// Update workspace
set((state) => {
const newDocuments = new Map(state.documents);
newDocuments.set(documentId, newDoc);
const newMetadata = new Map(state.documentMetadata);
newMetadata.set(documentId, metadata);
const newOrder = [...state.documentOrder, documentId];
const newState = {
documents: newDocuments,
documentMetadata: newMetadata,
documentOrder: newOrder,
activeDocumentId: documentId,
};
// Save workspace state
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: newOrder,
activeDocumentId: documentId,
settings: state.settings,
});
return newState;
});
return documentId;
},
// Load document from storage (if not already loaded)
loadDocument: async (documentId: string) => {
const state = get();
// Check if already loaded
if (state.documents.has(documentId)) {
return;
}
// Load from storage
const doc = loadDocumentFromStorage(documentId);
if (!doc) {
console.error(`Document ${documentId} not found`);
return;
}
// Load timeline if it exists
if (doc.timeline) {
useTimelineStore.getState().loadTimeline(documentId, doc.timeline as unknown as Timeline);
}
set((state) => {
const newDocuments = new Map(state.documents);
newDocuments.set(documentId, doc);
return { documents: newDocuments };
});
},
// Unload document from memory (but keep in storage and tab list)
unloadDocument: (documentId: string) => {
const state = get();
// Don't unload if it's the active document
if (documentId === state.activeDocumentId) {
console.warn('Cannot unload active document');
return;
}
// Don't unload if document has unsaved changes
const metadata = state.documentMetadata.get(documentId);
if (metadata?.isDirty) {
console.warn(`Cannot unload document with unsaved changes: ${documentId}`);
return;
}
set((state) => {
const newDocuments = new Map(state.documents);
newDocuments.delete(documentId);
return { documents: newDocuments };
});
},
// Close document (unload from memory, but keep in storage)
closeDocument: (documentId: string) => {
const state = get();
// Check for unsaved changes
const metadata = state.documentMetadata.get(documentId);
if (metadata?.isDirty) {
const confirmed = window.confirm(
`"${metadata.title}" has unsaved changes. Close anyway?`
);
if (!confirmed) return false;
}
set((state) => {
const newDocuments = new Map(state.documents);
newDocuments.delete(documentId);
const newOrder = state.documentOrder.filter(id => id !== documentId);
const newActiveId = state.activeDocumentId === documentId
? (newOrder[0] || null)
: state.activeDocumentId;
// Save workspace state
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: newOrder,
activeDocumentId: newActiveId,
settings: state.settings,
});
return {
documents: newDocuments,
documentOrder: newOrder,
activeDocumentId: newActiveId,
};
});
return true;
},
// Delete document (remove from storage)
deleteDocument: (documentId: string) => {
const state = get();
const metadata = state.documentMetadata.get(documentId);
const docTitle = metadata?.title || 'Untitled';
// Delete from storage
deleteDocumentFromStorage(documentId);
set((state) => {
const newDocuments = new Map(state.documents);
newDocuments.delete(documentId);
const newMetadata = new Map(state.documentMetadata);
newMetadata.delete(documentId);
const newOrder = state.documentOrder.filter(id => id !== documentId);
const newActiveId = state.activeDocumentId === documentId
? (newOrder[0] || null)
: state.activeDocumentId;
// Save workspace state
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: newOrder,
activeDocumentId: newActiveId,
settings: state.settings,
});
return {
documents: newDocuments,
documentMetadata: newMetadata,
documentOrder: newOrder,
activeDocumentId: newActiveId,
};
});
useToastStore.getState().showToast(`Document "${docTitle}" deleted`, 'info');
return true;
},
// Rename document
renameDocument: (documentId: string, newTitle: string) => {
set((state) => {
const doc = state.documents.get(documentId);
if (doc) {
doc.metadata.title = newTitle;
saveDocumentToStorage(documentId, doc);
}
const metadata = state.documentMetadata.get(documentId);
if (metadata) {
metadata.title = newTitle;
metadata.lastModified = new Date().toISOString();
saveDocumentMetadata(documentId, metadata);
const newMetadata = new Map(state.documentMetadata);
newMetadata.set(documentId, metadata);
return { documentMetadata: newMetadata };
}
return {};
});
useToastStore.getState().showToast(`Document renamed to "${newTitle}"`, 'success');
},
// Duplicate document
duplicateDocument: (documentId: string) => {
const state = get();
const sourceDoc = state.documents.get(documentId);
if (!sourceDoc) {
console.error(`Document ${documentId} not found`);
useToastStore.getState().showToast('Failed to duplicate: Document not found', 'error');
return '';
}
const newDocumentId = generateDocumentId();
const sourceMeta = state.documentMetadata.get(documentId);
const newTitle = `${sourceMeta?.title || 'Untitled'} (Copy)`;
const duplicatedDoc: ConstellationDocument = {
...sourceDoc,
metadata: {
...sourceDoc.metadata,
documentId: newDocumentId,
title: newTitle,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
};
const metadata: DocumentMetadata = {
id: newDocumentId,
title: newTitle,
isDirty: false,
lastModified: new Date().toISOString(),
};
// Save
saveDocumentToStorage(newDocumentId, duplicatedDoc);
saveDocumentMetadata(newDocumentId, metadata);
// Update workspace
set((state) => {
const newDocuments = new Map(state.documents);
newDocuments.set(newDocumentId, duplicatedDoc);
const newMetadata = new Map(state.documentMetadata);
newMetadata.set(newDocumentId, metadata);
const newOrder = [...state.documentOrder, newDocumentId];
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: newOrder,
activeDocumentId: state.activeDocumentId,
settings: state.settings,
});
return {
documents: newDocuments,
documentMetadata: newMetadata,
documentOrder: newOrder,
};
});
// Initialize timeline for duplicated document - always copy the timeline
// since all documents now have timelines
useTimelineStore.getState().loadTimeline(newDocumentId, duplicatedDoc.timeline as unknown as Timeline);
useToastStore.getState().showToast(`Document duplicated as "${newTitle}"`, 'success');
return newDocumentId;
},
// Switch active document (opens it as a tab if not already open)
switchToDocument: (documentId: string) => {
get().loadDocument(documentId).then(() => {
set((state) => {
// Add to documentOrder if not already there (reopen closed document)
const newOrder = state.documentOrder.includes(documentId)
? state.documentOrder
: [...state.documentOrder, documentId];
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: newOrder,
activeDocumentId: documentId,
settings: state.settings,
});
return {
documentOrder: newOrder,
activeDocumentId: documentId,
};
});
});
},
// Reorder documents
reorderDocuments: (newOrder: string[]) => {
set((state) => {
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: newOrder,
activeDocumentId: state.activeDocumentId,
settings: state.settings,
});
return { documentOrder: newOrder };
});
},
// Import document from file
importDocumentFromFile: async () => {
return new Promise((resolve) => {
selectFileForImport(
(data) => {
const documentId = generateDocumentId();
const now = new Date().toISOString();
// Serialize actors and relations for storage
const serializedNodes = serializeActors(data.nodes);
const serializedEdges = serializeRelations(data.edges);
const importedDoc = createDocumentHelper(
serializedNodes,
serializedEdges,
data.nodeTypes,
data.edgeTypes
);
importedDoc.metadata.documentId = documentId;
importedDoc.metadata.title = 'Imported Analysis';
const metadata: DocumentMetadata = {
id: documentId,
title: 'Imported Analysis',
isDirty: false,
lastModified: now,
};
saveDocumentToStorage(documentId, importedDoc);
saveDocumentMetadata(documentId, metadata);
// Load the timeline from the imported document into timelineStore
useTimelineStore.getState().loadTimeline(documentId, importedDoc.timeline as unknown as Timeline);
set((state) => {
const newDocuments = new Map(state.documents);
newDocuments.set(documentId, importedDoc);
const newMetadata = new Map(state.documentMetadata);
newMetadata.set(documentId, metadata);
const newOrder = [...state.documentOrder, documentId];
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: newOrder,
activeDocumentId: documentId,
settings: state.settings,
});
return {
documents: newDocuments,
documentMetadata: newMetadata,
documentOrder: newOrder,
activeDocumentId: documentId,
};
});
// Show success toast
useToastStore.getState().showToast('Document imported successfully', 'success');
resolve(documentId);
},
(error) => {
// Show error toast
useToastStore.getState().showToast(`Failed to import file: ${error}`, 'error', 5000);
resolve(null);
}
);
});
},
// Export document to file
exportDocument: (documentId: string) => {
const doc = get().documents.get(documentId);
if (!doc) {
console.error(`Document ${documentId} not found`);
useToastStore.getState().showToast('Failed to export: Document not found', 'error');
return;
}
try {
// Ensure timeline is up-to-date before exporting (similar to saveDocument)
const timelineState = useTimelineStore.getState();
const timeline = timelineState.timelines.get(documentId);
if (timeline) {
// Serialize timeline (convert Map to object)
const serializedStates: Record<string, ConstellationState> = {};
timeline.states.forEach((state: ConstellationState, id: string) => {
serializedStates[id] = state;
});
doc.timeline = {
states: serializedStates,
currentStateId: timeline.currentStateId,
rootStateId: timeline.rootStateId,
};
}
// Export the complete document with all timeline states
exportDocumentToFile(doc);
useToastStore.getState().showToast('Document exported successfully', 'success');
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
useToastStore.getState().showToast(`Failed to export document: ${message}`, 'error', 5000);
}
},
// Save workspace
saveWorkspace: () => {
const state = get();
saveWorkspaceState({
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: state.documentOrder,
activeDocumentId: state.activeDocumentId,
settings: state.settings,
});
},
// Load workspace
loadWorkspace: () => {
const loadedState = loadWorkspaceState();
if (loadedState) {
const metadata = loadAllDocumentMetadata();
set({
...loadedState,
documentMetadata: metadata,
documents: new Map(), // Documents loaded on demand
});
}
},
// Clear workspace
clearWorkspace: () => {
const confirmed = window.confirm(
'Are you sure you want to clear the entire workspace? This will delete all documents and cannot be undone.'
);
if (!confirmed) return;
clearWorkspaceStorage();
// Re-initialize with fresh workspace
const newState = initializeWorkspace();
set(newState);
},
// Get active document
getActiveDocument: () => {
const state = get();
if (!state.activeDocumentId) return null;
return state.documents.get(state.activeDocumentId) || null;
},
// Mark document as dirty
markDocumentDirty: (documentId: string) => {
set((state) => {
const metadata = state.documentMetadata.get(documentId);
if (metadata && !metadata.isDirty) {
metadata.isDirty = true;
const newMetadata = new Map(state.documentMetadata);
newMetadata.set(documentId, metadata);
saveDocumentMetadata(documentId, metadata);
return { documentMetadata: newMetadata };
}
return {};
});
},
// Save document
saveDocument: (documentId: string) => {
const state = get();
const doc = state.documents.get(documentId);
if (doc) {
doc.metadata.updatedAt = new Date().toISOString();
// Save global node and edge types from graph store
const graphStore = useGraphStore.getState();
doc.nodeTypes = graphStore.nodeTypes;
doc.edgeTypes = graphStore.edgeTypes;
// Save timeline data if exists
const timelineState = useTimelineStore.getState();
const timeline = timelineState.timelines.get(documentId);
if (timeline) {
// Serialize timeline (convert Map to object)
const serializedStates: Record<string, ConstellationState> = {};
timeline.states.forEach((state: ConstellationState, id: string) => {
serializedStates[id] = state;
});
doc.timeline = {
states: serializedStates,
currentStateId: timeline.currentStateId,
rootStateId: timeline.rootStateId,
};
}
saveDocumentToStorage(documentId, doc);
const metadata = state.documentMetadata.get(documentId);
if (metadata) {
metadata.isDirty = false;
metadata.lastModified = doc.metadata.updatedAt;
saveDocumentMetadata(documentId, metadata);
set((state) => {
const newMetadata = new Map(state.documentMetadata);
newMetadata.set(documentId, metadata);
return { documentMetadata: newMetadata };
});
}
}
},
// Export all documents as ZIP
exportAllDocumentsAsZip: async () => {
const state = get();
try {
// Ensure all documents are loaded
const allDocs = new Map<string, ConstellationDocument>();
for (const docId of state.documentOrder) {
let doc = state.documents.get(docId);
if (!doc) {
const loadedDoc = loadDocumentFromStorage(docId);
if (loadedDoc) {
doc = loadedDoc;
}
}
if (doc) {
allDocs.set(docId, doc);
}
}
await exportAllDocumentsAsZip(allDocs, state.workspaceName);
useToastStore.getState().showToast('All documents exported successfully', 'success');
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
useToastStore.getState().showToast(`Failed to export documents: ${message}`, 'error', 5000);
}
},
// Export workspace
exportWorkspace: async () => {
const state = get();
try {
const loadDoc = async (id: string): Promise<ConstellationDocument | null> => {
return loadDocumentFromStorage(id);
};
await exportWorkspaceToZip(
{
workspaceId: state.workspaceId,
workspaceName: state.workspaceName,
documentOrder: state.documentOrder,
activeDocumentId: state.activeDocumentId,
settings: state.settings,
},
state.documents,
state.documentOrder,
loadDoc
);
useToastStore.getState().showToast('Workspace exported successfully', 'success');
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
useToastStore.getState().showToast(`Failed to export workspace: ${message}`, 'error', 5000);
}
},
// Import workspace
importWorkspace: async () => {
return new Promise((resolve) => {
selectWorkspaceZipForImport(
(data) => {
const { workspaceState, documents } = data;
// Save workspace state
saveWorkspaceState(workspaceState);
// Save all documents
documents.forEach((doc, docId) => {
saveDocumentToStorage(docId, doc);
const metadata = {
id: docId,
title: doc.metadata.title || 'Untitled',
isDirty: false,
lastModified: doc.metadata.updatedAt || new Date().toISOString(),
};
saveDocumentMetadata(docId, metadata);
});
// Load metadata for all documents
const allMetadata = loadAllDocumentMetadata();
// Load active document
const activeDoc = workspaceState.activeDocumentId
? documents.get(workspaceState.activeDocumentId)
: null;
set({
...workspaceState,
documents: activeDoc && workspaceState.activeDocumentId
? new Map([[workspaceState.activeDocumentId, activeDoc]])
: new Map(),
documentMetadata: allMetadata,
});
useToastStore.getState().showToast('Workspace imported successfully', 'success');
resolve();
},
(error) => {
useToastStore.getState().showToast(`Failed to import workspace: ${error}`, 'error', 5000);
resolve();
}
);
});
},
// Save viewport state for a document
saveViewport: (documentId: string, viewport: { x: number; y: number; zoom: number }) => {
set((state) => {
const metadata = state.documentMetadata.get(documentId);
if (metadata) {
metadata.viewport = viewport;
const newMetadata = new Map(state.documentMetadata);
newMetadata.set(documentId, metadata);
saveDocumentMetadata(documentId, metadata);
return { documentMetadata: newMetadata };
}
return {};
});
},
// Get viewport state for a document
getViewport: (documentId: string) => {
const state = get();
const metadata = state.documentMetadata.get(documentId);
return metadata?.viewport;
},
}));