diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 8b9abb95..4ba33bdd 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -94,6 +94,7 @@ classDiagram +Number wordCount +String? snippet +Boolean archived + +WorkspaceIdentity? workspace +Boolean trashed ⚠ legacy +Number? trashedAt ⚠ legacy +Record~string,string~ properties @@ -144,6 +145,7 @@ interface VaultEntry { fileSize: number wordCount: number | null // Body word count (excludes frontmatter) snippet: string | null // First 200 chars of body + workspace?: WorkspaceIdentity // Mounted-workspace provenance for cross-vault graph entries archived: boolean // Archived flag trashed: boolean // Kept for backward compatibility (Trash system removed — delete is permanent) trashedAt: number | null // Kept for backward compatibility (Trash system removed) @@ -152,6 +154,27 @@ interface VaultEntry { } ``` +### WorkspaceIdentity + +Mounted workspace provenance is renderer-owned metadata attached to `VaultEntry.workspace` when entries are loaded through the registered workspace set. It is not parsed from note frontmatter and is not written into vault files. + +```typescript +interface WorkspaceIdentity { + id: string + label: string + alias: string // Stable prefix used in cross-workspace wikilinks + path: string // Absolute workspace root + shortLabel: string // Compact note-list badge text + color: string | null + icon: string | null + mounted: boolean + available: boolean + defaultForNewNotes: boolean +} +``` + +The status-bar workspace manager edits installation-local identity and mount state. The alias is the durable user-facing namespace for cross-workspace links such as `[[team/projects/alpha]]`; labels and colors are display affordances only. The default workspace controls where new notes and Type files are created, while active-vault switching still controls Git, folder tree, saved views, and watcher focus. + ### File kinds and binary previews `VaultEntry.fileKind` comes from the Rust vault scanner and intentionally stays coarse-grained: @@ -638,7 +661,7 @@ Two navigation mechanisms: 1. **Click handler**: DOM event listener on `.editor__blocknote-container` catches clicks on `.wikilink` elements → `onNavigateWikilink(target)`. 2. **Suggestion menu**: Typing `[[` triggers `SuggestionMenuController` with filtered vault entries. -Wikilink resolution (`resolveEntry` in `src/utils/wikilink.ts`) uses multi-pass matching with global priority: filename stem (strongest) → alias → exact title → humanized title (kebab-case → words). No path-based matching — flat vault uses title/filename only. Legacy path-style targets like `[[person/alice]]` are supported by extracting the last segment. +Wikilink resolution (`resolveEntry` in `src/utils/wikilink.ts`) uses multi-pass matching with global priority: path suffix for path-style targets, filename stem, alias, exact title, then humanized title (kebab-case -> words). In a mounted-workspace graph, unprefixed links prefer the source note's workspace, while links prefixed by a known workspace alias resolve inside that workspace (`[[team/projects/alpha]]`). Cross-workspace canonical link insertion prefixes the target alias only when source and target workspaces differ; same-workspace links stay vault-relative. ### Raw Editor Mode @@ -733,8 +756,11 @@ No indexing step required — search runs directly against the filesystem. - Persists vault list to `~/.config/com.tolaria.app/vaults.json` (reads legacy `com.laputa.app` on upgrade) - Switching closes all tabs and resets sidebar - Supports adding, removing, hiding/restoring vaults +- Persists workspace aliases, colors, mount state, and the default new-note destination for the unified graph - Default vault: public Getting Started starter vault cloned on demand +Mounted workspaces are loaded together by `useVaultLoader` for note-list, quick-open, keyword search, and wikilink navigation. Workspace switching remains a focus operation for per-vault capabilities (Git status, folders, saved views, AutoGit, watchers, and repair commands), not a graph isolation boundary. + ### Vault Config Per-vault settings stored locally and scoped by vault path: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 29228f49..1667dc47 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,6 +28,7 @@ When deciding where to persist a piece of data, ask: **"Would the user want this | Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files | | Per-vault All Notes note-list column overrides | All Notes PDF/image/unsupported file visibility | | Type `_sidebar_label` overrides | Whether this installation auto-pluralizes type labels | +| N/A | Registered workspace labels, aliases, mount state, and default new-note destination | | Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting | **Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.tolaria.app/settings.json` or localStorage. @@ -102,6 +103,12 @@ Vault opening is allowed to render the main app shell while the full entry scan Large-vault reproduction and keyboard QA steps live in [LARGE-VAULT-LOADING-QA.md](./LARGE-VAULT-LOADING-QA.md). +#### Mounted Workspaces + +The registered vault list can act as a mounted-workspace set. `useVaultSwitcher` persists each workspace's installation-local identity (`label`, stable `alias`, color, mount flag) and the default destination for newly created notes in `~/.config/com.tolaria.app/vaults.json`. `useVaultLoader` scans every available mounted workspace and annotates each `VaultEntry` with provenance before React consumes the combined graph. Switching the active vault still selects which Git controls, folder tree, views, watcher, and sync operations are in focus; it no longer restricts note-list, search, or wikilink navigation to only that vault's notes. + +Cross-workspace note reads and writes keep the disk-first invariant. When an absolute note path is saved or read without an explicit `vaultPath`, the Tauri boundary resolves the deepest registered vault root that contains the path and validates against that root before touching disk. This lets an editor tab opened from a mounted workspace save back to its source repository while preserving the same path-escape protections as active-vault operations. + #### Note Opening Fast Path Note opening uses bounded in-memory fast paths for raw content and parsed editor blocks. `useTabManagement` owns the markdown/text prefetch cache and treats every cached value as a performance hint only: identity-matched entries (`modifiedAt` + `fileSize`) can be reused immediately, while identity-missing or identity-mismatched cached text is checked with `validate_note_content`, which compares the cached text with the current file bytes inside the validated vault boundary. If validation fails, Tolaria discards the cached entry and reads fresh disk content before swapping the editor. diff --git a/docs/adr/0114-mounted-workspaces-unified-graph.md b/docs/adr/0114-mounted-workspaces-unified-graph.md new file mode 100644 index 00000000..e6d4d69e --- /dev/null +++ b/docs/adr/0114-mounted-workspaces-unified-graph.md @@ -0,0 +1,40 @@ +--- +type: ADR +id: "0114" +title: "Mounted workspaces unified graph" +status: active +date: 2026-05-07 +--- + +## Context + +Tolaria users can already register multiple vaults, but switching vaults historically replaced the active graph. That model breaks down when separate Git repositories represent different workspaces that still need to reference each other: search, quick-open, wikilink navigation, and note lists should see one graph, while Git status, folders, saved views, and sync controls remain scoped to the repository currently in focus. + +The app also needs a stable way to disambiguate same-named notes across repositories without writing machine-specific paths into Markdown. A full storage migration or database-backed graph would conflict with Tolaria's filesystem-first model and make separate Git histories harder to reason about. + +## Decision + +**Tolaria treats the registered vault list as an installation-local mounted-workspace set and annotates loaded entries with workspace provenance.** + +Specifically: + +1. `vaults.json` persists workspace identity (`label`, stable `alias`, color, mount flag) and the default workspace path for newly created notes. +2. `useVaultLoader` loads entries from every available mounted workspace and attaches `WorkspaceIdentity` to each `VaultEntry` before React consumes the combined graph. +3. Active-vault switching remains the focus control for Git, folder tree, saved views, watchers, repair, and other per-repository operations. +4. Wikilinks stay Markdown-first. Same-workspace links remain vault-relative; cross-workspace canonical links are prefixed with the target workspace alias. +5. Note reads and writes for absolute paths can resolve the deepest registered vault root at the Tauri boundary when no explicit `vaultPath` is supplied, preserving path-containment validation across mounted workspaces. + +## Alternatives considered + +- **Mounted workspace provenance on `VaultEntry` with alias-prefixed links** (chosen): preserves filesystem/Git independence while letting UI graph surfaces operate across repositories. +- **Merge separate repositories into one vault**: avoids cross-root resolution, but forces users to collapse unrelated Git histories and permissions into one repo. +- **Persist absolute paths in wikilinks**: disambiguates locally, but makes notes non-portable and leaks machine paths into user data. +- **Store a global graph database**: could make cross-workspace queries faster, but violates the cache-is-disposable rule and adds a new source of truth. + +## Consequences + +- Search, quick-open, note lists, and wikilink navigation can operate across mounted workspaces. +- UI surfaces that show ambiguous note names should display compact workspace provenance only when more than one workspace is present. +- New notes and Type files are created in the configured default workspace, falling back to the active workspace if the default is unavailable or unmounted. +- Backend command boundaries must continue validating every disk operation against a registered root; mounted workspaces do not loosen filesystem access. +- Future per-workspace features should distinguish graph-wide behavior from active-repository behavior before adding state or commands. diff --git a/docs/adr/README.md b/docs/adr/README.md index 6ff10c49..1310b3fa 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -167,3 +167,4 @@ proposed → active → superseded | [0111](0111-path-aware-external-vault-refresh-with-focused-editor-preservation.md) | Path-aware external vault refresh with focused-editor preservation | active | | [0112](0112-system-theme-mode.md) | System theme mode | active | | [0113](0113-shared-renderer-attachment-path-normalization.md) | Shared renderer attachment path normalization | active | +| [0114](0114-mounted-workspaces-unified-graph.md) | Mounted workspaces unified graph | active | diff --git a/lara.lock b/lara.lock index d837a7a5..e16e5cf1 100644 --- a/lara.lock +++ b/lara.lock @@ -517,10 +517,18 @@ files: status.vault.openLocal: a367344e0429a12a5cc9a6cecbbfcde5 status.vault.cloneGit: a7d0aef7c6237a36e54fd5a26e9c23fe status.vault.cloneGettingStarted: 9953a11a477b441976a626a9acf5d7df + status.vault.manageWorkspaces: d79ee0855873f0437b3e2552553203cd status.vault.notFound: 3119b7fa94c96209bca571b7c7ed0b7e status.vault.reloading: 893da50ef3a9e01d8a99ff5d3ceb55e9 status.vault.reloadingTooltip: 4a884bd7d113797ad16f905f70742da8 status.vault.remove: 7f3b4f76df23626170940dbc55c70728 + workspace.manager.title: 81cc2afb2d8a8fa388c0fa1f5e58cc1e + workspace.manager.description: 3957142a01fc42b5bcc00ffd1eef6fc9 + workspace.manager.default: 7a1920d61156abc05a60135aefe8bc67 + workspace.manager.makeDefault: 581e972562013d920e9b4d364265829e + workspace.manager.label: 91068740cd2c5f552ad5cff1470f88f5 + workspace.manager.alias: e89ff14f985995ab3af3bd8af66d019c + workspace.manager.mounted: 38ca22b706f01504d11883bc1984d45b status.remote.noneConfigured: 18a4edd4e8d862ccb343937f45585fb1 status.remote.inSync: a56f571b4df55d88fb06e61e343b3c91 status.remote.aheadTitle: 1516f4b92671b83c17441b4cddf25a7a diff --git a/src-tauri/src/commands/vault/boundary.rs b/src-tauri/src/commands/vault/boundary.rs index 2918c3bf..896464e9 100644 --- a/src-tauri/src/commands/vault/boundary.rs +++ b/src-tauri/src/commands/vault/boundary.rs @@ -125,6 +125,39 @@ fn load_configured_active_vault_root() -> Result, String> .transpose() } +fn load_registered_vault_roots() -> Result, String> { + let list = vault_list::load_vault_list()?; + let mut roots = list + .vaults + .iter() + .map(|entry| build_vault_root_paths(&entry.path)) + .collect::, _>>()?; + if let Some(active_vault) = list.active_vault.as_deref() { + if !active_vault.trim().is_empty() { + roots.push(build_vault_root_paths(active_vault)?); + } + } + Ok(roots) +} + +fn find_registered_root_for_absolute_path(raw_path: &str) -> Result, String> { + let requested = PathBuf::from(expand_tilde(raw_path).into_owned()); + if !requested.is_absolute() { + return Ok(None); + } + + let canonical = canonicalize_candidate_for_write(&requested)?; + let roots = match load_registered_vault_roots() { + Ok(roots) => roots, + Err(_) => return Ok(None), + }; + let root = roots + .into_iter() + .filter(|root| canonical.starts_with(&root.canonical)) + .max_by_key(|root| root.canonical.components().count()); + Ok(root) +} + fn build_vault_root_paths(raw_vault_path: &str) -> Result { let requested = PathBuf::from(expand_tilde(raw_vault_path).into_owned()); let canonical = requested @@ -232,6 +265,20 @@ pub(crate) fn with_validated_path( mode: ValidatedPathMode, action: impl FnOnce(&str) -> Result, ) -> Result { + if vault_path.is_none() { + if let Some(root) = find_registered_root_for_absolute_path(path)? { + let boundary = VaultBoundary { + requested_root: root.requested, + canonical_root: root.canonical, + }; + let validated_path = match mode { + ValidatedPathMode::Existing => boundary.validate_existing_path(path)?, + ValidatedPathMode::Writable => boundary.validate_writable_path(path)?, + }; + return action(&validated_path); + } + } + with_boundary(vault_path, |boundary| { let validated_path = match mode { ValidatedPathMode::Existing => boundary.validate_existing_path(path)?, diff --git a/src-tauri/src/commands/vault/scan_cmds.rs b/src-tauri/src/commands/vault/scan_cmds.rs index fb1e057b..b0f4d334 100644 --- a/src-tauri/src/commands/vault/scan_cmds.rs +++ b/src-tauri/src/commands/vault/scan_cmds.rs @@ -137,8 +137,10 @@ mod tests { vaults: vec![VaultListEntry { label: "Test".to_string(), path: vault_root.to_string_lossy().into_owned(), + ..Default::default() }], active_vault: None, + default_workspace_path: None, hidden_defaults: vec![], }; @@ -165,13 +167,16 @@ mod tests { VaultListEntry { label: "Parent".to_string(), path: parent_root.to_string_lossy().into_owned(), + ..Default::default() }, VaultListEntry { label: "Nested".to_string(), path: nested_root.to_string_lossy().into_owned(), + ..Default::default() }, ], active_vault: None, + default_workspace_path: None, hidden_defaults: vec![], }; @@ -205,8 +210,10 @@ mod tests { vaults: vec![VaultListEntry { label: "Listed".to_string(), path: "/listed".to_string(), + ..Default::default() }], active_vault: Some("/active".to_string()), + default_workspace_path: None, hidden_defaults: vec![], }; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6fb21964..ff2063d8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -540,6 +540,7 @@ mod tests { let list = VaultList { vaults: Vec::new(), active_vault: Some("/tmp/Selected Vault".to_string()), + default_workspace_path: None, hidden_defaults: Vec::new(), }; @@ -555,6 +556,7 @@ mod tests { let list = VaultList { vaults: Vec::new(), active_vault: Some(" ".to_string()), + default_workspace_path: None, hidden_defaults: Vec::new(), }; diff --git a/src-tauri/src/vault_list.rs b/src-tauri/src/vault_list.rs index e2de6a92..83dfb37c 100644 --- a/src-tauri/src/vault_list.rs +++ b/src-tauri/src/vault_list.rs @@ -5,10 +5,18 @@ use std::path::PathBuf; const APP_CONFIG_DIR: &str = "com.tolaria.app"; const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app"; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] pub struct VaultEntry { pub label: String, pub path: String, + #[serde(default)] + pub alias: Option, + #[serde(default)] + pub color: Option, + #[serde(default)] + pub icon: Option, + #[serde(default)] + pub mounted: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -16,6 +24,8 @@ pub struct VaultList { pub vaults: Vec, pub active_vault: Option, #[serde(default)] + pub default_workspace_path: Option, + #[serde(default)] pub hidden_defaults: Vec, } @@ -99,13 +109,16 @@ mod tests { VaultEntry { label: "My Vault".to_string(), path: "/Users/luca/Laputa".to_string(), + ..Default::default() }, VaultEntry { label: "Work".to_string(), path: "/Users/luca/Work".to_string(), + ..Default::default() }, ], active_vault: Some("/Users/luca/Laputa".to_string()), + default_workspace_path: None, hidden_defaults: vec![], }; let loaded = save_and_reload(&list); @@ -142,8 +155,10 @@ mod tests { vaults: vec![VaultEntry { label: "Test".to_string(), path: "/tmp/test".to_string(), + ..Default::default() }], active_vault: None, + default_workspace_path: None, hidden_defaults: vec![], }; save_at(&path, &list).unwrap(); @@ -186,6 +201,7 @@ mod tests { let list = VaultList { vaults: vec![], active_vault: None, + default_workspace_path: None, hidden_defaults: vec!["/Users/luca/Documents/Getting Started".to_string()], }; let loaded = save_and_reload(&list); @@ -196,6 +212,31 @@ mod tests { ); } + #[test] + fn workspace_metadata_roundtrip() { + let list = VaultList { + vaults: vec![VaultEntry { + label: "Team Notes".to_string(), + path: "/tmp/team".to_string(), + alias: Some("team".to_string()), + color: Some("green".to_string()), + icon: Some("briefcase".to_string()), + mounted: Some(false), + }], + active_vault: Some("/tmp/personal".to_string()), + default_workspace_path: Some("/tmp/team".to_string()), + hidden_defaults: vec![], + }; + + let loaded = save_and_reload(&list); + + assert_eq!(loaded.default_workspace_path.as_deref(), Some("/tmp/team")); + assert_eq!(loaded.vaults[0].alias.as_deref(), Some("team")); + assert_eq!(loaded.vaults[0].color.as_deref(), Some("green")); + assert_eq!(loaded.vaults[0].icon.as_deref(), Some("briefcase")); + assert_eq!(loaded.vaults[0].mounted, Some(false)); + } + #[test] fn load_legacy_format_without_hidden_defaults() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/src/App.test.tsx b/src/App.test.tsx index 4a888766..c5f8dcbf 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -957,8 +957,16 @@ describe('App', () => { await waitFor(() => { expect(saveVaultList).toHaveBeenCalledWith({ list: { - vaults: [{ label: 'Work Vault', path: selectedVaultPath }], + vaults: [{ + label: 'Work Vault', + path: selectedVaultPath, + alias: null, + color: null, + icon: null, + mounted: true, + }], active_vault: selectedVaultPath, + default_workspace_path: selectedVaultPath, hidden_defaults: [], }, }) @@ -1009,6 +1017,7 @@ describe('App', () => { list: { vaults: [], active_vault: expectedDefaultVaultPath, + default_workspace_path: expectedDefaultVaultPath, hidden_defaults: [], }, }) diff --git a/src/App.tsx b/src/App.tsx index e82852bf..c3ba7d85 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -130,6 +130,7 @@ import { sanitizeSelectionForOrganization, } from './utils/organizationWorkflow' import { requestPlainTextPaste } from './utils/plainTextPaste' +import { workspacesMountedInGraph } from './utils/workspaces' import './App.css' const ACTIVE_EDITOR_SURFACE_SELECTOR = '.editor__blocknote-container, .raw-editor-codemirror' @@ -294,6 +295,7 @@ function App() { }) const { allVaults, + defaultWorkspacePath, registerVaultSelection, selectedVaultPath, syncVaultSelection, @@ -342,6 +344,16 @@ function App() { ? onboarding.state.vaultPath : vaultSwitcher.vaultPath ) + const graphVaults = useMemo( + () => noteWindowParams ? [] : workspacesMountedInGraph(allVaults, resolvedPath), + [allVaults, noteWindowParams, resolvedPath], + ) + const writableVaultPaths = useMemo( + () => graphVaults + .filter((workspace) => workspace.available !== false && workspace.mounted !== false) + .map((workspace) => workspace.path), + [graphVaults], + ) // Git repo check: 'checking' | 'missing' | 'ready' const [gitRepoState, setGitRepoState] = useState<'checking' | 'missing' | 'ready'>('checking') const [showGitSetupDialog, setShowGitSetupDialog] = useState(false) @@ -390,7 +402,7 @@ function App() { setToastMessage('Git initialized for this vault') }, [resolvedPath, setToastMessage]) - const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath) + const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath, graphVaults, defaultWorkspacePath) const runtimeMissingVaultPath = !noteWindowParams ? vault.unavailableVaultPath : null const { markInternalWrite: markRecentVaultWrite, @@ -609,6 +621,8 @@ function App() { setToastMessage, updateEntry: vault.updateEntry, vaultPath: resolvedPath, + defaultWorkspacePath: noteWindowParams ? null : defaultWorkspacePath, + vaults: graphVaults, addPendingSave: handleCreatedVaultEntryPersisting, removePendingSave: vault.removePendingSave, trackUnsaved: vault.trackUnsaved, @@ -827,6 +841,7 @@ function App() { tabs: notes.tabs, activeTabPath: notes.activeTabPath, handleRenameNote: notes.handleRenameNote, handleRenameFilename: notes.handleRenameFilename, replaceEntry: vault.replaceEntry, resolvedPath, + writableVaultPaths, initialH1AutoRenameEnabled: settings.initial_h1_auto_rename_enabled !== false, onInternalVaultWrite: markRecentVaultWrite, locale: appLocale, @@ -1817,7 +1832,7 @@ function App() { - handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} defaultAiTarget={settings.default_ai_target ?? undefined} aiModelProviders={settings.ai_model_providers ?? []} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onSetDefaultAiTarget={aiAgentPreferences.setDefaultAiTarget} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} locale={appLocale} /> + handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onSetDefaultWorkspace={vaultSwitcher.setDefaultWorkspace} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} defaultAiTarget={settings.default_ai_target ?? undefined} aiModelProviders={settings.ai_model_providers ?? []} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onSetDefaultAiTarget={aiAgentPreferences.setDefaultAiTarget} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} locale={appLocale} /> setToastMessage(null)} /> diff --git a/src/components/NoteItem.tsx b/src/components/NoteItem.tsx index caa9e9d6..779428e3 100644 --- a/src/components/NoteItem.tsx +++ b/src/components/NoteItem.tsx @@ -13,6 +13,7 @@ import { filePreviewKind, type FilePreviewKind } from '../utils/filePreview' import { NoteTitleIcon } from './NoteTitleIcon' import { PropertyChips } from './note-item/PropertyChips' import { ChangeNoteContent } from './note-item/ChangeNoteContent' +import { workspaceForEntry } from '../utils/workspaces' const TYPE_ICON_MAP: Record>> = { Project: Wrench, @@ -67,6 +68,23 @@ function StateBadge({ archived }: { archived: boolean }) { return null } +function WorkspaceBadge({ entry, allEntries }: { entry: VaultEntry; allEntries: VaultEntry[] }) { + const workspace = workspaceForEntry(entry) + const hasMultipleWorkspaces = new Set(allEntries.map((candidate) => candidate.workspace?.alias).filter(Boolean)).size > 1 + if (!workspace || !hasMultipleWorkspaces) return null + + return ( + + {workspace.shortLabel} + + ) +} + type NoteItemVisualState = { isUnavailableBinary: boolean isSelected: boolean @@ -190,6 +208,7 @@ function InteractiveNoteDetails({ <> } {entry.title} + {!isBinary && } ) diff --git a/src/components/SearchPanel.tsx b/src/components/SearchPanel.tsx index 21fe0b5c..d6a69ab7 100644 --- a/src/components/SearchPanel.tsx +++ b/src/components/SearchPanel.tsx @@ -8,6 +8,7 @@ import { formatSearchSubtitle } from '../utils/noteListHelpers' import { scrollSelectedHTMLChildIntoView } from '../utils/domScroll' import { getTypeIcon } from './NoteItem' import { NoteTitleIcon } from './NoteTitleIcon' +import { workspaceDisplayPrefix } from '../utils/workspaces' interface SearchPanelProps { open: boolean @@ -43,10 +44,27 @@ function nextSearchSelectionIndex( return Math.max(currentIndex - 1, 0) } +function workspaceTitlePrefix(entry: VaultEntry | undefined, showWorkspace: boolean): string | null { + if (!entry || !showWorkspace) return null + return workspaceDisplayPrefix(entry) +} + +function searchVaultPathsForEntries(entries: VaultEntry[], fallbackVaultPath: string): string | string[] { + const paths = entries + .map((entry) => entry.workspace?.path) + .filter((path): path is string => !!path) + return paths.length > 0 ? [...new Set(paths)] : fallbackVaultPath +} + +function shouldShowWorkspace(entries: VaultEntry[]): boolean { + return new Set(entries.map((entry) => entry.workspace?.alias).filter(Boolean)).size > 1 +} + export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }: SearchPanelProps) { + const searchVaultPaths = useMemo(() => searchVaultPathsForEntries(entries, vaultPath), [entries, vaultPath]) const { query, setQuery, results, selectedIndex, setSelectedIndex, loading, elapsedMs, - } = useUnifiedSearch(vaultPath, open) + } = useUnifiedSearch(searchVaultPaths, open) const inputRef = useRef(null) const listRef = useRef(null) @@ -106,6 +124,7 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }: for (const e of entries) map.set(e.path, e) return map }, [entries]) + const showWorkspace = useMemo(() => shouldShowWorkspace(entries), [entries]) if (!open) return null @@ -133,6 +152,7 @@ export function SearchPanel({ open, vaultPath, entries, onSelectNote, onClose }: elapsedMs={elapsedMs} entryLookup={entryLookup} typeEntryMap={typeEntryMap} + showWorkspace={showWorkspace} listRef={listRef} onSelect={handleSelect} onHover={setSelectedIndex} @@ -192,6 +212,7 @@ interface SearchContentProps { elapsedMs: number | null entryLookup: Map typeEntryMap: Record + showWorkspace: boolean listRef: React.RefObject onSelect: (result: SearchResult) => void onHover: (index: number) => void @@ -203,12 +224,13 @@ interface SearchResultRowProps { selected: boolean index: number typeEntryMap: Record + showWorkspace: boolean onSelect: (result: SearchResult) => void onHover: (index: number) => void } function SearchResultRow({ - result, entry, selected, index, typeEntryMap, onSelect, onHover, + result, entry, selected, index, typeEntryMap, showWorkspace, onSelect, onHover, }: SearchResultRowProps) { const isA = entry?.isA ?? result.noteType const noteType = isA || null @@ -216,6 +238,7 @@ function SearchResultRow({ const typeColor = noteType ? getTypeColor(isA, te?.color) : undefined const TypeIcon = getTypeIcon(isA ?? null, te?.icon) const subtitle = entry ? formatSearchSubtitle(entry) : null + const titlePrefix = workspaceTitlePrefix(entry, showWorkspace) return (
- - {entry?.title ?? result.title} - - {noteType && ( - {noteType} - )} + +
- {subtitle && ( -

- {subtitle} -

- )} + + + ) +} + +function SearchResultTitle({ icon, prefix, title }: { icon?: string | null; prefix: string | null; title: string }) { + return ( + + + {prefix} + {title} + + ) +} + +function SearchResultTypeLabel({ noteType }: { noteType: string | null }) { + return noteType ? {noteType} : null +} + +function SearchResultSubtitle({ subtitle }: { subtitle: string | null }) { + return subtitle ?

{subtitle}

: null +} + +function SearchIdleMessage() { + return ( +
+

Search across all note contents

+

Enter to open · Esc to close

+
+ ) +} + +function SearchLoadingMessage() { + return
Searching...
+} + +function SearchNoResultsMessage() { + return ( +
+

No results found

+
+ ) +} + +function SearchResultsHeader({ count, elapsedMs }: { count: number; elapsedMs: number | null }) { + return ( +
+ + {count} result{count !== 1 ? 's' : ''}{elapsedMs !== null ? ` · ${elapsedMs}ms` : ''} +
) } function SearchContent({ - query, results, selectedIndex, loading, elapsedMs, entryLookup, typeEntryMap, listRef, onSelect, onHover, + query, results, selectedIndex, loading, elapsedMs, entryLookup, typeEntryMap, showWorkspace, listRef, onSelect, onHover, }: SearchContentProps) { + const hasQuery = query.trim().length > 0 + const hasResults = results.length > 0 return (
- {!query.trim() && ( -
-

Search across all note contents

-

- Enter to open · Esc to close -

-
- )} - - {query.trim() && results.length === 0 && loading && ( -
- Searching... -
- )} - - {query.trim() && results.length === 0 && !loading && ( -
-

No results found

-
- )} - - {results.length > 0 && ( + {!hasQuery && } + {hasQuery && !hasResults && loading && } + {hasQuery && !hasResults && !loading && } + {hasResults && ( <> -
- - {results.length} result{results.length !== 1 ? 's' : ''}{elapsedMs !== null ? ` · ${elapsedMs}ms` : ''} - -
+
{results.map((result, i) => ( diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 3e2a75dd..4d4335b7 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -61,6 +61,7 @@ interface StatusBarProps { modifiedCount?: number vaultPath: string vaults: VaultOption[] + defaultWorkspacePath?: string | null onSwitchVault: (path: string) => void onOpenSettings?: () => void onOpenLocalFolder?: () => void @@ -90,6 +91,8 @@ interface StatusBarProps { buildNumber?: string onCheckForUpdates?: () => void onRemoveVault?: (path: string) => void + onSetDefaultWorkspace?: (path: string) => void + onUpdateWorkspaceIdentity?: (path: string, patch: Partial) => void mcpStatus?: McpStatus onInstallMcp?: () => void aiAgentsStatus?: AiAgentsStatus @@ -114,6 +117,7 @@ function StatusBarPrimaryFromFooter({ modifiedCount = 0, vaultPath, vaults, + defaultWorkspacePath, onSwitchVault, onOpenLocalFolder, onCreateEmptyVault, @@ -136,6 +140,8 @@ function StatusBarPrimaryFromFooter({ buildNumber, onCheckForUpdates, onRemoveVault, + onSetDefaultWorkspace, + onUpdateWorkspaceIdentity, mcpStatus, onInstallMcp, aiAgentsStatus, @@ -157,6 +163,7 @@ function StatusBarPrimaryFromFooter({ modifiedCount={modifiedCount} vaultPath={vaultPath} vaults={vaults} + defaultWorkspacePath={defaultWorkspacePath} onSwitchVault={onSwitchVault} onOpenLocalFolder={onOpenLocalFolder} onCreateEmptyVault={onCreateEmptyVault} @@ -179,6 +186,8 @@ function StatusBarPrimaryFromFooter({ buildNumber={buildNumber} onCheckForUpdates={onCheckForUpdates} onRemoveVault={onRemoveVault} + onSetDefaultWorkspace={onSetDefaultWorkspace} + onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity} mcpStatus={mcpStatus} onInstallMcp={onInstallMcp} aiAgentsStatus={aiAgentsStatus} diff --git a/src/components/status-bar/StatusBarSections.tsx b/src/components/status-bar/StatusBarSections.tsx index f5408b27..4e7373ef 100644 --- a/src/components/status-bar/StatusBarSections.tsx +++ b/src/components/status-bar/StatusBarSections.tsx @@ -44,6 +44,7 @@ interface StatusBarPrimarySectionProps { modifiedCount: number vaultPath: string vaults: VaultOption[] + defaultWorkspacePath?: string | null onSwitchVault: (path: string) => void onOpenLocalFolder?: () => void onCreateEmptyVault?: () => void @@ -67,6 +68,8 @@ interface StatusBarPrimarySectionProps { buildNumber?: string onCheckForUpdates?: () => void onRemoveVault?: (path: string) => void + onSetDefaultWorkspace?: (path: string) => void + onUpdateWorkspaceIdentity?: (path: string, patch: Partial) => void mcpStatus?: McpStatus onInstallMcp?: () => void aiAgentsStatus?: AiAgentsStatus @@ -400,7 +403,7 @@ function PrimarySeparator({ compact }: { compact: boolean }) { export function StatusBarPrimarySection({ modifiedCount, vaultPath, - vaults, + vaults, defaultWorkspacePath, onSwitchVault, onOpenLocalFolder, onCreateEmptyVault, @@ -411,9 +414,7 @@ export function StatusBarPrimarySection({ onClickPulse, onCommitPush, onInitializeGit, - isOffline = false, - isVaultReloading = false, - isGitVault = true, + isOffline = false, isVaultReloading = false, isGitVault = true, syncStatus, lastSyncTime, conflictCount, @@ -423,7 +424,7 @@ export function StatusBarPrimarySection({ onOpenConflictResolver, buildNumber, onCheckForUpdates, - onRemoveVault, + onRemoveVault, onSetDefaultWorkspace, onUpdateWorkspaceIdentity, mcpStatus, onInstallMcp, aiAgentsStatus, @@ -454,18 +455,19 @@ export function StatusBarPrimarySection({ }) return ( -
+
diff --git a/src/components/status-bar/VaultMenu.tsx b/src/components/status-bar/VaultMenu.tsx index 9e4a7e01..a6780dd1 100644 --- a/src/components/status-bar/VaultMenu.tsx +++ b/src/components/status-bar/VaultMenu.tsx @@ -1,11 +1,16 @@ import { useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' -import { AlertTriangle, Check, FolderOpen, GitBranch, Plus, Rocket, X } from 'lucide-react' +import { AlertTriangle, Check, FolderOpen, GitBranch, Plus, Rocket, Settings2, X } from 'lucide-react' import { ActionTooltip } from '@/components/ui/action-tooltip' import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' import { translate, type AppLocale, type TranslationKey } from '../../lib/i18n' +import { trackEvent } from '../../lib/telemetry' import type { VaultOption } from './types' import { useDismissibleLayer } from './useDismissibleLayer' +import { WORKSPACE_COLORS, workspaceIdentityFromVault } from '../../utils/workspaces' interface VaultMenuProps { vaults: VaultOption[] @@ -16,6 +21,9 @@ interface VaultMenuProps { onCloneVault?: () => void onCloneGettingStarted?: () => void onRemoveVault?: (path: string) => void + defaultWorkspacePath?: string | null + onSetDefaultWorkspace?: (path: string) => void + onUpdateWorkspaceIdentity?: (path: string, patch: Partial) => void compact?: boolean locale?: AppLocale } @@ -37,6 +45,23 @@ interface VaultMenuActionProps { onClick: () => void } +interface WorkspaceManagerProps { + defaultWorkspacePath?: string | null + locale: AppLocale + onOpenChange: (open: boolean) => void + onSetDefaultWorkspace?: (path: string) => void + onUpdateWorkspaceIdentity?: (path: string, patch: Partial) => void + open: boolean + vaults: VaultOption[] +} + +interface WorkspaceRowProps extends Pick< + WorkspaceManagerProps, + 'defaultWorkspacePath' | 'locale' | 'onSetDefaultWorkspace' | 'onUpdateWorkspaceIdentity' +> { + vault: VaultOption +} + interface VaultAction { key: string icon: ReactNode @@ -59,13 +84,27 @@ function getVaultTriggerClassName(open: boolean, compact: boolean) { } function buildVaultActions({ + onManageWorkspaces, onCreateEmptyVault, onCloneGettingStarted, onCloneVault, onOpenLocalFolder, -}: Pick): VaultAction[] { +}: Pick & { + onManageWorkspaces?: () => void +}): VaultAction[] { const items: VaultAction[] = [] + if (onManageWorkspaces) { + items.push({ + key: 'manage-workspaces', + icon: , + labelKey: 'status.vault.manageWorkspaces', + testId: 'vault-menu-manage-workspaces', + accent: true, + onClick: onManageWorkspaces, + }) + } + if (onCreateEmptyVault) { items.push({ key: 'create-empty', @@ -189,19 +228,225 @@ function VaultMenuAction({ icon, labelKey, testId, accent = false, onClick, loca ) } -export function VaultMenu({ +function WorkspaceColorButton({ + color, + selected, + onSelect, +}: { + color: string + selected: boolean + onSelect: () => void +}) { + return ( + +
+ ) +} + +function WorkspaceMountedControl({ + canEdit, + locale, + onUpdateWorkspaceIdentity, + vault, + workspace, +}: Pick & { + canEdit: boolean + workspace: ReturnType +}) { + const handleMountedChange = (mounted: boolean) => { + onUpdateWorkspaceIdentity?.(vault.path, { mounted }) + trackEvent('workspace_mount_changed', { workspace_alias: workspace.alias, mounted: mounted ? 1 : 0 }) + } + + return ( + + ) +} + +function WorkspaceIdentityInputs({ + canEdit, + locale, + onUpdateWorkspaceIdentity, + vault, + workspace, +}: Pick & { + canEdit: boolean + workspace: ReturnType +}) { + return ( +
+ canEdit && onUpdateWorkspaceIdentity?.(vault.path, { label: event.target.value })} + aria-label={translate(locale, 'workspace.manager.label')} + disabled={!canEdit} + /> + canEdit && onUpdateWorkspaceIdentity?.(vault.path, { alias: event.target.value })} + aria-label={translate(locale, 'workspace.manager.alias')} + disabled={!canEdit} + /> +
+ ) +} + +function WorkspaceColorPicker({ + canEdit, + onUpdateWorkspaceIdentity, + vault, +}: Pick & { + canEdit: boolean +}) { + return ( +
+ {WORKSPACE_COLORS.map((color) => ( + canEdit && onUpdateWorkspaceIdentity?.(vault.path, { color })} + /> + ))} +
+ ) +} + +function WorkspaceRow({ + defaultWorkspacePath, + locale, + onSetDefaultWorkspace, + onUpdateWorkspaceIdentity, + vault, +}: WorkspaceRowProps) { + const workspace = workspaceIdentityFromVault(vault, { defaultWorkspacePath }) + const canEdit = !!onUpdateWorkspaceIdentity && vault.path !== '' && !vault.managedDefault + + return ( +
+ + + + +
+ ) +} + +function WorkspaceManager({ + defaultWorkspacePath, + locale, + onOpenChange, + onSetDefaultWorkspace, + onUpdateWorkspaceIdentity, + open, vaults, - vaultPath, - onSwitchVault, - onOpenLocalFolder, - onCreateEmptyVault, - onCloneVault, - onCloneGettingStarted, - onRemoveVault, - compact = false, - locale = 'en', -}: VaultMenuProps) { +}: WorkspaceManagerProps) { + return ( + + + + {translate(locale, 'workspace.manager.title')} + {translate(locale, 'workspace.manager.description')} + +
+ {vaults.map((vault) => ( + + ))} +
+
+
+ ) +} + +export function VaultMenu(props: VaultMenuProps) { + const { + vaults, vaultPath, onSwitchVault, onOpenLocalFolder, onCreateEmptyVault, + onCloneVault, onCloneGettingStarted, onRemoveVault, defaultWorkspacePath, + onSetDefaultWorkspace, onUpdateWorkspaceIdentity, compact = false, locale = 'en', + } = props const [open, setOpen] = useState(false) + const [manageOpen, setManageOpen] = useState(false) const menuRef = useRef(null) const activeVault = vaults.find((vault) => vault.path === vaultPath) const canRemove = !!onRemoveVault && vaults.length > 1 @@ -213,15 +458,28 @@ export function VaultMenu({ const actions = useMemo(() => { return buildVaultActions({ + onManageWorkspaces: () => { + setManageOpen(true) + trackEvent('workspace_manager_opened', { workspace_count: vaults.length }) + }, onCreateEmptyVault, onCloneGettingStarted, onCloneVault, onOpenLocalFolder, }) - }, [onCreateEmptyVault, onCloneGettingStarted, onCloneVault, onOpenLocalFolder]) + }, [onCreateEmptyVault, onCloneGettingStarted, onCloneVault, onOpenLocalFolder, vaults.length]) return (
+