diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 7ebcc125..7a55265c 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -371,6 +371,8 @@ In a mounted-workspace graph, each loaded `ViewFile` carries optional renderer-o `useAppViewActions()` owns the renderer-side saved View lifecycle: choosing the target workspace, preserving mounted-view identity, saving/deleting YAML definitions, reloading affected vault state, and exposing the available note-list fields for the create/edit dialog. `App.tsx` wires those callbacks into `Sidebar`, `NoteList`, `CreateViewDialog`, and command surfaces without duplicating the persistence rules. +`useMcpSetupDialogController()` owns MCP setup dialog state, busy actions, and manual config callbacks so `App.tsx` only passes the controller into settings/status surfaces. `useAiWorkspaceWindowBridgeEvents()` owns native AI-workspace event subscriptions and listener cleanup for popped-out workspace windows. + The renderer uses `viewOrdering` helpers to convert drag or command-palette move intent into dense order updates before saving each affected view file through `save_view_cmd`. The sidebar treats saved View rows like Type rows for direct customization: double-click starts inline rename, right-click opens edit/rename/icon-color/delete actions, and keyboard users can open that same menu from the focused row while command-palette actions remain responsible for saved View ordering. ### Neighborhood Mode diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c472b473..1352b28b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -869,6 +869,8 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks: | `useAppWindowControls` | view mode, panel visibility, command refs, zoom/build labels | Keeps main-window sizing and editor command ref plumbing out of `App.tsx` | | `useAppViewActions` | saved-view/type creation and saved-view mutation callbacks | Keeps saved-view persistence and Type auto-creation orchestration out of `App.tsx` | | `useAiWorkspacePublishedContext` | AI workspace note-list snapshot and BroadcastChannel context publishing | Keeps AI workspace context derivation close to its cross-window publication side effect | +| `useMcpSetupDialogController` | MCP setup dialog state/actions | Keeps MCP status, manual config, and connect/disconnect dialog flow out of `App.tsx` | +| `useAiWorkspaceWindowBridgeEvents` | native AI workspace event subscriptions | Owns Tauri listener setup/cleanup for popped-out workspace window bridge events | | `useGitFileWorkflows` | git diff/history/discard callbacks | Resolves note-scoped repository paths and owns deleted-file preview and queued diff side effects | | `useVaultRenameDetection` | detected rename banner state | Detects external Git renames on focus and owns the wikilink update callback | | `useNoteCreation` | — | Note/type creation with optimistic persistence | diff --git a/src/App.tsx b/src/App.tsx index 5867f100..c353831e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,7 +21,6 @@ import { FeedbackDialog } from './components/FeedbackDialog' import { McpSetupDialog } from './components/McpSetupDialog' import { NoteRetargetingDialogs } from './components/note-retargeting/NoteRetargetingDialogs' import { StartupScreen } from './components/StartupScreen' -import { useMcpStatus } from './hooks/useMcpStatus' import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding' import { useAiAgentsStatus } from './hooks/useAiAgentsStatus' import { useVaultAiGuidanceStatus } from './hooks/useVaultAiGuidanceStatus' @@ -123,6 +122,8 @@ import { useStartupScreenState } from './hooks/useStartupScreenState' import { useGitFileWorkflows } from './hooks/useGitFileWorkflows' import { useAutoGitWork } from './hooks/useAutoGitWork' import { useAppAiWorkspaceBridge } from './hooks/useAppAiWorkspaceBridge' +import { useAiWorkspaceWindowBridgeEvents } from './hooks/useAiWorkspaceWindowBridgeEvents' +import { useMcpSetupDialogController } from './hooks/useMcpSetupDialogController' import { activeVaultModifiedFiles, aiWorkspaceWindowContextForPath, @@ -132,13 +133,6 @@ import { runNativeTextHistoryCommand, shouldPreferOnboardingVaultPath, } from './utils/appOrchestration' -import { cleanupTauriEventListeners, type TauriUnlisten } from './utils/tauriEventCleanup' -import { - AI_WORKSPACE_FILE_CREATED_EVENT, - AI_WORKSPACE_FILE_MODIFIED_EVENT, - AI_WORKSPACE_OPEN_NOTE_REQUESTED_EVENT, - AI_WORKSPACE_VAULT_CHANGED_EVENT, -} from './utils/aiPromptBridge' import './App.css' // Type declarations for mock content storage and test overrides @@ -190,8 +184,6 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu const dialogs = useDialogs() const { closeAIChat, openAIChat, showAIChat } = dialogs const [showFeedback, setShowFeedback] = useState(false) - const [showMcpSetupDialog, setShowMcpSetupDialog] = useState(false) - const [mcpDialogAction, setMcpDialogAction] = useState<'connect' | 'disconnect' | null>(null) const openFeedback = useCallback(() => setShowFeedback(true), []) const closeFeedback = useCallback(() => setShowFeedback(false), []) const openDocs = useCallback(() => { @@ -419,16 +411,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu gitRepoState, resolvedPath, }) - const { - mcpStatus, - connectMcp, - disconnectMcp, - mcpConfigSnippet, - mcpConfigLoading, - mcpConfigError, - loadMcpConfigSnippet, - copyMcpConfig, - } = useMcpStatus(resolvedPath, setToastMessage, appLocale) + const mcpSetupDialog = useMcpSetupDialogController(resolvedPath, setToastMessage, appLocale) const loadDefaultVaultModifiedFiles = vault.loadModifiedFiles const loadAllGitModifiedFiles = gitSurfaces.loadAllModifiedFiles const loadModifiedFilesForRepository = gitSurfaces.loadModifiedFilesForRepository @@ -455,39 +438,6 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu void refreshAllGitRemoteStatuses() }, [gitFeaturesEnabled, gitRepoState, loadVaultModifiedFiles, refreshAllGitRemoteStatuses, refreshGitRemoteStatus]) - const openMcpSetupDialog = useCallback(() => { - setShowMcpSetupDialog(true) - }, []) - - const closeMcpSetupDialog = useCallback(() => { - if (mcpDialogAction !== null) return - setShowMcpSetupDialog(false) - }, [mcpDialogAction]) - - const handleConnectMcp = useCallback(async () => { - setMcpDialogAction('connect') - try { - const didConnect = await connectMcp() - if (didConnect) setShowMcpSetupDialog(false) - } finally { - setMcpDialogAction(null) - } - }, [connectMcp]) - - const handleDisconnectMcp = useCallback(async () => { - setMcpDialogAction('disconnect') - try { - const didDisconnect = await disconnectMcp() - if (didDisconnect) setShowMcpSetupDialog(false) - } finally { - setMcpDialogAction(null) - } - }, [disconnectMcp]) - - const handleCopyMcpConfig = useCallback(() => { - void copyMcpConfig() - }, [copyMcpConfig]) - const handleOpenSettings = useCallback(() => { setSettingsInitialSectionId(null) dialogs.openSettings() @@ -498,10 +448,6 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu dialogs.openSettings() }, [dialogs]) - const handleLoadMcpConfigSnippet = useCallback(() => { - void loadMcpConfigSnippet().catch(() => undefined) - }, [loadMcpConfigSnippet]) - const { detectedRenames, handleUpdateWikilinks, @@ -575,6 +521,8 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu closeAllTabs, openTabWithContent, } = notes + const noteActiveTabPath = notes.activeTabPath + const noteActiveTabPathRef = notes.activeTabPathRef useNoteWindowLifecycle({ activeTabPath: notes.activeTabPath, handleSelectNote, @@ -589,9 +537,9 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu ) => { const updateVaultPath = options.vaultPath ?? resolvedPath await refreshPulledVaultState({ - activeTabPath: notes.activeTabPath, + activeTabPath: noteActiveTabPath, closeAllTabs, - getActiveTabPath: () => notes.activeTabPathRef.current, + getActiveTabPath: () => noteActiveTabPathRef.current, hasUnsavedChanges: (path) => vault.unsavedPaths.has(path), shouldKeepActiveEditorMounted: options.preserveFocusedEditor ? isActiveElementInsideEditorSurface @@ -607,8 +555,8 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu }, [ closeAllTabs, handleReplaceActiveTab, - notes.activeTabPath, - notes.activeTabPathRef, + noteActiveTabPath, + noteActiveTabPathRef, refreshGitModifiedFiles, resolvedPath, vault.reloadFolders, @@ -705,46 +653,12 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu handleAgentFileModified: handleAiWorkspaceWindowFileModified, handleAgentVaultChanged: handleAiWorkspaceWindowVaultChanged, } = vaultBridge - useEffect(() => { - if (!isTauri()) return - - let disposed = false - let unlisteners: TauriUnlisten[] = [] - - void import('@tauri-apps/api/event') - .then(({ listen }) => Promise.all([ - listen(AI_WORKSPACE_OPEN_NOTE_REQUESTED_EVENT, (event) => { - if (typeof event.payload === 'string') handleAiWorkspaceWindowOpenNote(event.payload) - }), - listen(AI_WORKSPACE_FILE_CREATED_EVENT, (event) => { - if (typeof event.payload === 'string') handleAiWorkspaceWindowFileCreated(event.payload) - }), - listen(AI_WORKSPACE_FILE_MODIFIED_EVENT, (event) => { - if (typeof event.payload === 'string') handleAiWorkspaceWindowFileModified(event.payload) - }), - listen(AI_WORKSPACE_VAULT_CHANGED_EVENT, () => { - handleAiWorkspaceWindowVaultChanged() - }), - ])) - .then((nextUnlisteners) => { - if (disposed) { - cleanupTauriEventListeners(nextUnlisteners) - return - } - unlisteners = nextUnlisteners - }) - .catch(() => undefined) - - return () => { - disposed = true - cleanupTauriEventListeners(unlisteners) - } - }, [ - handleAiWorkspaceWindowFileCreated, - handleAiWorkspaceWindowFileModified, - handleAiWorkspaceWindowOpenNote, - handleAiWorkspaceWindowVaultChanged, - ]) + useAiWorkspaceWindowBridgeEvents({ + onFileCreated: handleAiWorkspaceWindowFileCreated, + onFileModified: handleAiWorkspaceWindowFileModified, + onOpenNote: handleAiWorkspaceWindowOpenNote, + onVaultChanged: handleAiWorkspaceWindowVaultChanged, + }) const conflictFlow = useConflictFlow({ resolvedPath: autoSync.conflictVaultPath ?? graphDefaultWorkspacePath, @@ -1412,7 +1326,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu runtimeMissingVaultPath, selectedVaultPath, settingsLoaded, - showMcpSetupDialog, + showMcpSetupDialog: mcpSetupDialog.open, telemetryConsent: settings.telemetry_consent, vaultIsLoading: vault.isLoading, vaultSwitcher, @@ -1517,8 +1431,8 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu selectedUiLanguage, onSetUiLanguage: handleSetUiLanguage, onSetThemeMode: handleSetThemeMode, - mcpStatus, - onInstallMcp: openMcpSetupDialog, + mcpStatus: mcpSetupDialog.status, + onInstallMcp: mcpSetupDialog.openDialog, onReloadVault: handleManualVaultReload, onRepairVault: handleRepairVault, onSetNoteIcon: handleSetNoteIconCommand, @@ -1610,7 +1524,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu settings={settings} settingsLoaded={settingsLoaded} shouldResumeFreshStartOnboarding={shouldResumeFreshStartOnboarding} - showMcpSetupDialog={showMcpSetupDialog} + showMcpSetupDialog={mcpSetupDialog.open} setToastMessage={setToastMessage} toastMessage={toastMessage} vaultSwitcher={vaultSwitcher} @@ -1733,7 +1647,7 @@ function MainApp({ noteWindowParams }: { noteWindowParams: NoteWindowParams | nu - handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.syncRepositoryPath} onRepositoryChange={gitSurfaces.setSyncRepositoryPath} onTriggerSync={handlePullSelectedRepository} onPullAndPush={handlePullAndPushSelectedRepository} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} locale={appLocale} /> + handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} repositories={gitRepositories} selectedRepositoryPath={gitSurfaces.syncRepositoryPath} onRepositoryChange={gitSurfaces.setSyncRepositoryPath} onTriggerSync={handlePullSelectedRepository} onPullAndPush={handlePullAndPushSelectedRepository} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpSetupDialog.status} onInstallMcp={mcpSetupDialog.openDialog} locale={appLocale} /> {aiFeaturesEnabled && !effectiveShowAIChat ? ( - + - + {deleteActions.confirmDelete && ( void + onFileModified: (path: string) => void + onOpenNote: (path: string) => void + onVaultChanged: () => void +} + +export function useAiWorkspaceWindowBridgeEvents({ + onFileCreated, + onFileModified, + onOpenNote, + onVaultChanged, +}: AiWorkspaceWindowBridgeEvents) { + useEffect(() => { + if (!isTauri()) return + + let disposed = false + let unlisteners: TauriUnlisten[] = [] + + void import('@tauri-apps/api/event') + .then(({ listen }) => Promise.all([ + listen(AI_WORKSPACE_OPEN_NOTE_REQUESTED_EVENT, (event) => { + if (typeof event.payload === 'string') onOpenNote(event.payload) + }), + listen(AI_WORKSPACE_FILE_CREATED_EVENT, (event) => { + if (typeof event.payload === 'string') onFileCreated(event.payload) + }), + listen(AI_WORKSPACE_FILE_MODIFIED_EVENT, (event) => { + if (typeof event.payload === 'string') onFileModified(event.payload) + }), + listen(AI_WORKSPACE_VAULT_CHANGED_EVENT, () => { + onVaultChanged() + }), + ])) + .then((nextUnlisteners) => { + if (disposed) { + cleanupTauriEventListeners(nextUnlisteners) + return + } + unlisteners = nextUnlisteners + }) + .catch(() => undefined) + + return () => { + disposed = true + cleanupTauriEventListeners(unlisteners) + } + }, [ + onFileCreated, + onFileModified, + onOpenNote, + onVaultChanged, + ]) +} diff --git a/src/hooks/useMcpSetupDialogController.ts b/src/hooks/useMcpSetupDialogController.ts new file mode 100644 index 00000000..d0dee34d --- /dev/null +++ b/src/hooks/useMcpSetupDialogController.ts @@ -0,0 +1,77 @@ +import { useCallback, useState } from 'react' +import { useMcpStatus } from './useMcpStatus' +import type { AppLocale } from '../lib/i18n' + +type ToastHandler = (message: string) => void +type McpDialogAction = 'connect' | 'disconnect' | null + +export function useMcpSetupDialogController( + vaultPath: string, + onToast: ToastHandler, + locale: AppLocale, +) { + const [open, setOpen] = useState(false) + const [busyAction, setBusyAction] = useState(null) + const { + mcpStatus, + connectMcp, + disconnectMcp, + mcpConfigSnippet, + mcpConfigLoading, + mcpConfigError, + loadMcpConfigSnippet, + copyMcpConfig, + } = useMcpStatus(vaultPath, onToast, locale) + + const openDialog = useCallback(() => { + setOpen(true) + }, []) + + const closeDialog = useCallback(() => { + if (busyAction !== null) return + setOpen(false) + }, [busyAction]) + + const connect = useCallback(async () => { + setBusyAction('connect') + try { + const didConnect = await connectMcp() + if (didConnect) setOpen(false) + } finally { + setBusyAction(null) + } + }, [connectMcp]) + + const disconnect = useCallback(async () => { + setBusyAction('disconnect') + try { + const didDisconnect = await disconnectMcp() + if (didDisconnect) setOpen(false) + } finally { + setBusyAction(null) + } + }, [disconnectMcp]) + + const copyManualConfig = useCallback(() => { + void copyMcpConfig() + }, [copyMcpConfig]) + + const loadManualConfig = useCallback(() => { + void loadMcpConfigSnippet().catch(() => undefined) + }, [loadMcpConfigSnippet]) + + return { + busyAction, + closeDialog, + connect, + copyManualConfig, + disconnect, + loadManualConfig, + manualConfigError: mcpConfigError, + manualConfigLoading: mcpConfigLoading, + manualConfigSnippet: mcpConfigSnippet, + open, + openDialog, + status: mcpStatus, + } +}