Something went wrong. Try again.
[mirror] A local-first desktop markdown writer with grammar checking, at proto integration, and more github.com/stormlightlabs/writer/releases
Something went wrong. Try again.
18 kB · 545 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546import { dirCreate, dirList, dirMove, docDelete, docList, docMove, docRename, docSave, locationAddViaDialog, locationList, locationRemove, runCmd, sessionCloseTab, sessionDropDoc, sessionGet, sessionMarkTabModified, sessionOpenTab, sessionPruneLocations, sessionReorderTabs, sessionSelectTab, sessionUpdateTabDoc,} from "$ports";import { useTabsActions, useTabsState, useWorkspaceDocumentsActions, useWorkspaceDocumentsState, useWorkspaceLocationsActions, useWorkspaceLocationsState,} from "$state/selectors";import { useWorkspaceStore } from "$state/stores/workspace";import type { SidebarRefreshReason } from "$state/types";import type { AppError, DocMeta, DocRef, SessionState, Tab } from "$types";import { buildDraftRelPath, getDraftTitle } from "$utils/paths";import { f } from "$utils/serialize";import * as logger from "@tauri-apps/plugin-log";import { useCallback, useEffect, useMemo } from "react";
const TRANSIENT_EMPTY_REFRESH_RETRY_DELAY_MS = 120;
type RefreshSidebarOptions = { source?: SidebarRefreshReason; attempt?: number };
function toLocationId(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined;}
function areDocumentsEqual(left: DocMeta[], right: DocMeta[]): boolean { if (left.length !== right.length) { return false; }
for (let index = 0; index < left.length; index += 1) { const a = left[index]; const b = right[index]; if ( a.location_id !== b.location_id || a.rel_path !== b.rel_path || a.title !== b.title || a.updated_at !== b.updated_at || a.word_count !== b.word_count ) { return false; } }
return true;}
function areDirectoriesEqual(left: string[], right: string[]): boolean { if (left.length !== right.length) { return false; }
for (let index = 0; index < left.length; index += 1) { if (left[index] !== right[index]) { return false; } }
return true;}
function mapDirectoryMovedRelPath(sourceDir: string, destinationDir: string, candidate: string): string | null { if (candidate === sourceDir) { return destinationDir; }
const prefix = `${sourceDir}/`; if (!candidate.startsWith(prefix)) { return null; }
const suffix = candidate.slice(prefix.length); return suffix ? `${destinationDir}/${suffix}` : destinationDir;}
export function useWorkspaceController() { const { locations, selectedLocationId, isLoadingLocations, sidebarFilter } = useWorkspaceLocationsState(); const { selectedDocPath, documents, documentsByLocation, isLoadingDocuments, refreshingLocationId, sidebarRefreshReason, } = useWorkspaceDocumentsState(); const { setSidebarRefreshState, setDocumentsForLocation, setDirectoriesForLocation } = useWorkspaceDocumentsActions(); const { setSidebarFilter, setSelectedLocation, setLocations } = useWorkspaceLocationsActions(); const { tabs, activeTabId, isSessionHydrated } = useTabsState(); const { applySessionState } = useTabsActions(); const activeTab = useMemo(() => tabs.find((tab) => tab.id === activeTabId) ?? null, [activeTabId, tabs]);
const applySession = useCallback((session: SessionState) => { applySessionState(session); }, [applySessionState]);
useEffect(() => { void runCmd(sessionGet(applySession, (error) => { logger.error(f("Failed to load session state", { error })); applySession({ tabs: [], activeTabId: null }); })); }, [applySession]);
useEffect(() => { if (!isSessionHydrated || isLoadingLocations) { return; }
const validLocationIds = locations.map((location) => location.id); void runCmd(sessionPruneLocations(validLocationIds, applySession, (error) => { logger.error(f("Failed to prune session tabs by location", { error, validLocationIds })); })); }, [locations, isSessionHydrated, isLoadingLocations, applySession]);
const locationDocuments = useMemo( () => (selectedLocationId ? documents.filter((doc) => doc.location_id === selectedLocationId) : []), [documents, selectedLocationId], );
const refreshLocations = useCallback((nextSelectedLocationId?: number) => { runCmd(locationList((nextLocations) => { setLocations(nextLocations); if (nextSelectedLocationId && nextLocations.some((location) => location.id === nextSelectedLocationId)) { setSelectedLocation(nextSelectedLocationId); } }, (error) => { logger.error(f("Failed to refresh locations", { error })); })); }, [setLocations, setSelectedLocation]);
const handleAddLocation = useCallback(() => { runCmd(locationAddViaDialog((location) => { refreshLocations(location.id); }, (error) => { logger.error(f("Failed to add location", { error })); })); }, [refreshLocations]);
const handleRemoveLocation = useCallback((locationId: number) => { runCmd(locationRemove(locationId, (removed) => { if (removed) { refreshLocations(); } }, (error) => { logger.error(f("Failed to remove location", { locationId, error })); })); }, [refreshLocations]);
const openTab = useCallback((docRef: DocRef, title: string) => { void runCmd(sessionOpenTab(docRef, title, applySession, (error) => { logger.error(f("Failed to open session tab", { docRef, title, error })); })); }, [applySession]);
const handleSelectDocument = useCallback((locationId: number, path: string) => { const docTitle = (useWorkspaceStore.getState().documentsByLocation[locationId] ?? []).find((doc) => doc.location_id === locationId && doc.rel_path === path )?.title;
const title = docTitle || path.split("/").pop() || "Untitled"; openTab({ location_id: locationId, rel_path: path }, title); }, [openTab]);
const handleSelectLocation = setSelectedLocation;
const handleSelectTab = useCallback((tabId: string) => { void runCmd(sessionSelectTab(tabId, applySession, (error) => { logger.error(f("Failed to select session tab", { tabId, error })); })); }, [applySession]);
const handleCloseTab = useCallback((tabId: string) => { void runCmd(sessionCloseTab(tabId, applySession, (error) => { logger.error(f("Failed to close session tab", { tabId, error })); })); }, [applySession]);
const handleReorderTabs = useCallback((nextTabs: Tab[]) => { void runCmd(sessionReorderTabs(nextTabs.map((tab) => tab.id), applySession, (error) => { logger.error(f("Failed to reorder session tabs", { error })); })); }, [applySession]);
const markActiveTabModified = useCallback((isModified: boolean) => { if (!activeTabId) { return; }
void runCmd(sessionMarkTabModified(activeTabId, isModified, applySession, (error) => { logger.error(f("Failed to mark session tab modified", { activeTabId, isModified, error })); })); }, [activeTabId, applySession]);
const handleCreateDraftTab = useCallback((docRef: DocRef, title: string) => { openTab(docRef, title); }, [openTab]);
const handleCreateNewDocument = useCallback((locationId?: number) => { const workspaceState = useWorkspaceStore.getState(); const requestedLocationId = toLocationId(locationId); const targetLocationId = requestedLocationId ?? workspaceState.selectedLocationId ?? workspaceState.locations[0]?.id;
if (!targetLocationId) { logger.warn("Cannot create draft without a selected location."); return null; }
const relPath = buildDraftRelPath( targetLocationId, workspaceState.documentsByLocation[targetLocationId] ?? [], tabs, ); const docRef: DocRef = { location_id: targetLocationId, rel_path: relPath }; openTab(docRef, getDraftTitle(relPath)); return docRef; }, [openTab, tabs]);
const handleRefreshSidebar = useCallback((locationId?: number, options: RefreshSidebarOptions = {}) => { const source = options.source ?? "manual"; const attempt = options.attempt ?? 0; const workspaceState = useWorkspaceStore.getState(); const requestedLocationId = toLocationId(locationId); const targetLocationId = requestedLocationId ?? workspaceState.selectedLocationId ?? workspaceState.locations[0]?.id;
if (!targetLocationId) { return; }
setSidebarRefreshState(targetLocationId, source);
runCmd(dirList(targetLocationId, (nextDirectories) => { const latestState = useWorkspaceStore.getState(); const hasCachedDirectories = Object.prototype.hasOwnProperty.call( latestState.directoriesByLocation, targetLocationId, ); const currentDirectories = latestState.directoriesByLocation[targetLocationId] ?? [];
if (!hasCachedDirectories || !areDirectoriesEqual(currentDirectories, nextDirectories)) { setDirectoriesForLocation(targetLocationId, nextDirectories); } }, (error) => { logger.error(f("Failed to refresh sidebar directories", { locationId: targetLocationId, error })); }));
runCmd(docList(targetLocationId, (nextDocuments) => { const latestState = useWorkspaceStore.getState(); const hasCachedDocuments = Object.prototype.hasOwnProperty.call( latestState.documentsByLocation, targetLocationId, ); const currentDocuments = latestState.documentsByLocation[targetLocationId] ?? [];
if (nextDocuments.length === 0 && currentDocuments.length > 0 && attempt === 0) { setTimeout(() => { handleRefreshSidebar(targetLocationId, { source, attempt: attempt + 1 }); }, TRANSIENT_EMPTY_REFRESH_RETRY_DELAY_MS); return; }
if (!hasCachedDocuments || !areDocumentsEqual(currentDocuments, nextDocuments)) { setDocumentsForLocation(targetLocationId, nextDocuments); }
if (latestState.refreshingLocationId === targetLocationId) { latestState.setSidebarRefreshState(undefined, null); } }, (error) => { if (attempt === 0) { setTimeout(() => { handleRefreshSidebar(targetLocationId, { source, attempt: attempt + 1 }); }, TRANSIENT_EMPTY_REFRESH_RETRY_DELAY_MS); return; }
logger.error(f("Failed to refresh sidebar documents", { locationId: targetLocationId, error })); const latestState = useWorkspaceStore.getState(); if (latestState.refreshingLocationId === targetLocationId) { latestState.setSidebarRefreshState(undefined, null); } })); }, [setDirectoriesForLocation, setDocumentsForLocation, setSidebarRefreshState]);
const handleRenameDocument = useCallback((locationId: number, relPath: string, newName: string): Promise<boolean> => { return new Promise((resolve) => { runCmd(docRename(locationId, relPath, newName, (newMeta) => { void runCmd( sessionUpdateTabDoc( locationId, relPath, { location_id: locationId, rel_path: newMeta.rel_path }, newMeta.title, applySession, () => {}, ), );
logger.info(f("Document renamed", { locationId, oldPath: relPath, newPath: newMeta.rel_path })); resolve(true); }, (error: AppError) => { logger.error(f("Failed to rename document", { locationId, relPath, newName, error })); resolve(false); })); }); }, [applySession]);
const handleMoveDocument = useCallback( (locationId: number, relPath: string, newRelPath: string, targetLocationId?: number): Promise<boolean> => { return new Promise((resolve) => { runCmd(docMove(locationId, relPath, newRelPath, (newMeta) => { void runCmd( sessionUpdateTabDoc( locationId, relPath, { location_id: newMeta.location_id, rel_path: newMeta.rel_path }, newMeta.title, applySession, () => {}, ), );
logger.info( f("Document moved", { sourceLocationId: locationId, targetLocationId: newMeta.location_id, oldPath: relPath, newPath: newMeta.rel_path, }), ); resolve(true); }, (error: AppError) => { logger.error(f("Failed to move document", { locationId, relPath, newRelPath, targetLocationId, error })); resolve(false); }, targetLocationId)); }); }, [applySession], );
const handleDeleteDocument = useCallback((locationId: number, relPath: string): Promise<boolean> => { return new Promise((resolve) => { runCmd(docDelete(locationId, relPath, (deleted) => { if (!deleted) { resolve(false); return; }
void runCmd(sessionDropDoc(locationId, relPath, applySession, () => {}));
logger.info(f("Document deleted", { locationId, relPath })); resolve(true); }, (error: AppError) => { logger.error(f("Failed to delete document", { locationId, relPath, error })); resolve(false); })); }); }, [applySession]);
const handleMoveDirectory = useCallback( (locationId: number, relPath: string, newRelPath: string, targetLocationId?: number): Promise<boolean> => { return new Promise((resolve) => { const resolvedTargetLocationId = targetLocationId ?? locationId; runCmd(dirMove(locationId, relPath, newRelPath, (resolvedPath) => { for (const tab of tabs) { if (tab.docRef.location_id !== locationId) { continue; }
const remappedRelPath = mapDirectoryMovedRelPath(relPath, resolvedPath, tab.docRef.rel_path); if (!remappedRelPath) { continue; }
void runCmd( sessionUpdateTabDoc( locationId, tab.docRef.rel_path, { location_id: resolvedTargetLocationId, rel_path: remappedRelPath }, tab.title, applySession, () => {}, ), ); }
logger.info( f("Directory moved", { sourceLocationId: locationId, targetLocationId: resolvedTargetLocationId, relPath, newRelPath: resolvedPath, }), ); resolve(true); }, (error: AppError) => { logger.error( f("Failed to move directory", { sourceLocationId: locationId, targetLocationId: resolvedTargetLocationId, relPath, newRelPath, error, }), ); resolve(false); }, resolvedTargetLocationId)); }); }, [applySession, tabs], );
const handleImportExternalFile = useCallback( (locationId: number, relPath: string, content: string): Promise<boolean> => { return new Promise((resolve) => { runCmd(docSave(locationId, relPath, content, (result) => { logger.info(f("External file imported", { locationId, relPath, result })); resolve(true); }, (error: AppError) => { logger.error(f("Failed to import external file", { locationId, relPath, error })); resolve(false); })); }); }, [], );
const handleCreateDirectory = useCallback( (locationId: number, parentRelPath: string, newDirectoryName: string): Promise<boolean> => { return new Promise((resolve) => { const trimmedName = newDirectoryName.trim(); if (!trimmedName) { resolve(false); return; }
const normalizedParent = parentRelPath.trim().replaceAll(/[/\\]+$/g, ""); const directoryRelPath = normalizedParent ? `${normalizedParent}/${trimmedName}` : trimmedName;
runCmd(dirCreate(locationId, directoryRelPath, (created) => { logger.info(f("Directory create command completed", { locationId, directoryRelPath, created })); resolve(created); }, (error: AppError) => { logger.error(f("Failed to create nested directory", { locationId, directoryRelPath, error })); resolve(false); })); }); }, [], );
return useMemo( () => ({ locations, documents, selectedLocationId, selectedDocPath, documentsByLocation, locationDocuments, sidebarFilter, isSidebarLoading: isLoadingLocations || isLoadingDocuments, isSessionHydrated, refreshingLocationId, sidebarRefreshReason, tabs, activeTabId, activeTab, setSidebarFilter, markActiveTabModified, handleAddLocation, handleRemoveLocation, handleSelectLocation, handleSelectDocument, handleSelectTab, handleCloseTab, handleReorderTabs, handleCreateDraftTab, handleCreateNewDocument, handleRefreshSidebar, handleRenameDocument, handleMoveDocument, handleMoveDirectory, handleDeleteDocument, handleCreateDirectory, handleImportExternalFile, }), [ locations, documents, selectedLocationId, selectedDocPath, documentsByLocation, locationDocuments, sidebarFilter, isLoadingLocations, isLoadingDocuments, isSessionHydrated, refreshingLocationId, sidebarRefreshReason, tabs, activeTabId, activeTab, setSidebarFilter, markActiveTabModified, handleAddLocation, handleRemoveLocation, handleSelectLocation, handleSelectDocument, handleSelectTab, handleCloseTab, handleReorderTabs, handleCreateDraftTab, handleCreateNewDocument, handleRefreshSidebar, handleRenameDocument, handleMoveDocument, handleMoveDirectory, handleDeleteDocument, handleCreateDirectory, handleImportExternalFile, ], );}