feat: unify mounted workspace graph

This commit is contained in:
lucaronin
2026-05-08 00:45:21 +02:00
parent 7bda58bbf6
commit ef3aed64e3
55 changed files with 1707 additions and 189 deletions

View File

@@ -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:

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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

View File

@@ -125,6 +125,39 @@ fn load_configured_active_vault_root() -> Result<Option<VaultRootPaths>, String>
.transpose()
}
fn load_registered_vault_roots() -> Result<Vec<VaultRootPaths>, String> {
let list = vault_list::load_vault_list()?;
let mut roots = list
.vaults
.iter()
.map(|entry| build_vault_root_paths(&entry.path))
.collect::<Result<Vec<_>, _>>()?;
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<Option<VaultRootPaths>, 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<VaultRootPaths, String> {
let requested = PathBuf::from(expand_tilde(raw_vault_path).into_owned());
let canonical = requested
@@ -232,6 +265,20 @@ pub(crate) fn with_validated_path<T>(
mode: ValidatedPathMode,
action: impl FnOnce(&str) -> Result<T, String>,
) -> Result<T, String> {
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)?,

View File

@@ -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![],
};

View File

@@ -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(),
};

View File

@@ -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<String>,
#[serde(default)]
pub color: Option<String>,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub mounted: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -16,6 +24,8 @@ pub struct VaultList {
pub vaults: Vec<VaultEntry>,
pub active_vault: Option<String>,
#[serde(default)]
pub default_workspace_path: Option<String>,
#[serde(default)]
pub hidden_defaults: Vec<String>,
}
@@ -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();

View File

@@ -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: [],
},
})

View File

@@ -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() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} locale={appLocale} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => 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} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} defaultWorkspacePath={vaultSwitcher.defaultWorkspacePath} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenDocs={openDocs} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => 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} />
<GitSetupDialog open={shouldShowGitSetupDialog} onInitGit={handleInitGitRepo} onDismiss={dismissGitSetupDialog} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />

View File

@@ -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<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
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 (
<span
className="ml-1.5 inline-flex h-[16px] min-w-[18px] items-center justify-center rounded-sm px-1 align-middle text-[9px] font-semibold text-white"
style={{ background: workspace.color ? `var(--accent-${workspace.color})` : 'var(--muted-foreground)' }}
title={`${workspace.label} (${workspace.alias})`}
aria-label={`Workspace ${workspace.label}`}
>
{workspace.shortLabel}
</span>
)
}
type NoteItemVisualState = {
isUnavailableBinary: boolean
isSelected: boolean
@@ -190,6 +208,7 @@ function InteractiveNoteDetails({
<>
<NoteTitleRow
entry={entry}
allEntries={allEntries}
isBinary={false}
isSelected={isSelected}
noteStatus={noteStatus}
@@ -251,6 +270,7 @@ function StandardNoteContent({
{isBinary ? (
<NoteTitleRow
entry={entry}
allEntries={allEntries}
isBinary={isUnavailableBinary}
isSelected={isSelected}
noteStatus={noteStatus}
@@ -273,11 +293,13 @@ function StandardNoteContent({
function NoteTitleRow({
entry,
allEntries,
isBinary,
isSelected,
noteStatus,
}: {
entry: VaultEntry
allEntries: VaultEntry[]
isBinary: boolean
isSelected: boolean
noteStatus: NoteStatus
@@ -287,6 +309,7 @@ function NoteTitleRow({
{hasStatusDot(noteStatus) && !isBinary && <StatusDot noteStatus={noteStatus} />}
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" testId="note-title-icon" />
{entry.title}
<WorkspaceBadge entry={entry} allEntries={allEntries} />
{!isBinary && <StateBadge archived={entry.archived} />}
</div>
)

View File

@@ -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<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(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<string, VaultEntry>
typeEntryMap: Record<string, VaultEntry>
showWorkspace: boolean
listRef: React.RefObject<HTMLDivElement | null>
onSelect: (result: SearchResult) => void
onHover: (index: number) => void
@@ -203,12 +224,13 @@ interface SearchResultRowProps {
selected: boolean
index: number
typeEntryMap: Record<string, VaultEntry>
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 (
<div
@@ -233,56 +256,76 @@ function SearchResultRow({
className: 'shrink-0',
style: { color: typeColor ?? 'var(--muted-foreground)' },
})}
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
<NoteTitleIcon icon={entry?.icon} size={14} className="mr-1" />
{entry?.title ?? result.title}
</span>
{noteType && (
<span className="shrink-0 text-[11px] text-muted-foreground/70">{noteType}</span>
)}
<SearchResultTitle icon={entry?.icon} prefix={titlePrefix} title={entry?.title ?? result.title} />
<SearchResultTypeLabel noteType={noteType} />
</div>
{subtitle && (
<p className="mt-0.5 pl-[22px] text-[11px] text-muted-foreground">
{subtitle}
</p>
)}
<SearchResultSubtitle subtitle={subtitle} />
</div>
)
}
function SearchResultTitle({ icon, prefix, title }: { icon?: string | null; prefix: string | null; title: string }) {
return (
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
<NoteTitleIcon icon={icon} size={14} className="mr-1" />
{prefix}
{title}
</span>
)
}
function SearchResultTypeLabel({ noteType }: { noteType: string | null }) {
return noteType ? <span className="shrink-0 text-[11px] text-muted-foreground/70">{noteType}</span> : null
}
function SearchResultSubtitle({ subtitle }: { subtitle: string | null }) {
return subtitle ? <p className="mt-0.5 pl-[22px] text-[11px] text-muted-foreground">{subtitle}</p> : null
}
function SearchIdleMessage() {
return (
<div className="px-4 py-8 text-center">
<p className="text-[13px] text-muted-foreground">Search across all note contents</p>
<p className="mt-1 text-[11px] text-muted-foreground/60">Enter to open · Esc to close</p>
</div>
)
}
function SearchLoadingMessage() {
return <div className="px-4 py-8 text-center text-[13px] text-muted-foreground">Searching...</div>
}
function SearchNoResultsMessage() {
return (
<div className="px-4 py-8 text-center">
<p className="text-[13px] text-muted-foreground">No results found</p>
</div>
)
}
function SearchResultsHeader({ count, elapsedMs }: { count: number; elapsedMs: number | null }) {
return (
<div className="border-b border-border/50 px-4 py-1.5">
<span className="text-[11px] text-muted-foreground">
{count} result{count !== 1 ? 's' : ''}{elapsedMs !== null ? ` · ${elapsedMs}ms` : ''}
</span>
</div>
)
}
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 (
<div className="flex-1 overflow-y-auto">
{!query.trim() && (
<div className="px-4 py-8 text-center">
<p className="text-[13px] text-muted-foreground">Search across all note contents</p>
<p className="mt-1 text-[11px] text-muted-foreground/60">
Enter to open · Esc to close
</p>
</div>
)}
{query.trim() && results.length === 0 && loading && (
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
Searching...
</div>
)}
{query.trim() && results.length === 0 && !loading && (
<div className="px-4 py-8 text-center">
<p className="text-[13px] text-muted-foreground">No results found</p>
</div>
)}
{results.length > 0 && (
{!hasQuery && <SearchIdleMessage />}
{hasQuery && !hasResults && loading && <SearchLoadingMessage />}
{hasQuery && !hasResults && !loading && <SearchNoResultsMessage />}
{hasResults && (
<>
<div className="border-b border-border/50 px-4 py-1.5">
<span className="text-[11px] text-muted-foreground">
{results.length} result{results.length !== 1 ? 's' : ''}{elapsedMs !== null ? ` · ${elapsedMs}ms` : ''}
</span>
</div>
<SearchResultsHeader count={results.length} elapsedMs={elapsedMs} />
<div ref={listRef}>
{results.map((result, i) => (
<SearchResultRow
@@ -292,6 +335,7 @@ function SearchContent({
selected={i === selectedIndex}
index={i}
typeEntryMap={typeEntryMap}
showWorkspace={showWorkspace}
onSelect={onSelect}
onHover={onHover}
/>

View File

@@ -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<VaultOption>) => 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}

View File

@@ -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<VaultOption>) => 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 (
<div
style={primarySectionStyle(stacked, compact)}
>
<div style={primarySectionStyle(stacked, compact)}>
<VaultMenu
vaults={vaults}
vaultPath={vaultPath}
defaultWorkspacePath={defaultWorkspacePath}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCreateEmptyVault={onCreateEmptyVault}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onRemoveVault={onRemoveVault}
onSetDefaultWorkspace={onSetDefaultWorkspace}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
compact={compact}
locale={locale}
/>

View File

@@ -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<VaultOption>) => 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<VaultOption>) => 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<VaultMenuProps, 'onCreateEmptyVault' | 'onCloneGettingStarted' | 'onCloneVault' | 'onOpenLocalFolder'>): VaultAction[] {
}: Pick<VaultMenuProps, 'onCreateEmptyVault' | 'onCloneGettingStarted' | 'onCloneVault' | 'onOpenLocalFolder'> & {
onManageWorkspaces?: () => void
}): VaultAction[] {
const items: VaultAction[] = []
if (onManageWorkspaces) {
items.push({
key: 'manage-workspaces',
icon: <Settings2 size={12} />,
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 (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-5 w-5 rounded-sm p-0"
style={{
background: selected ? `var(--accent-${color})` : 'transparent',
border: `1px solid var(--accent-${color})`,
}}
aria-label={color}
onClick={onSelect}
/>
)
}
function workspaceBadgeClassName(): string {
return 'inline-flex h-5 min-w-6 items-center justify-center rounded-sm px-1 text-[10px] font-semibold text-white'
}
function WorkspaceBadge({ workspace }: { workspace: ReturnType<typeof workspaceIdentityFromVault> }) {
return (
<span
className={workspaceBadgeClassName()}
style={{ background: workspace.color ? `var(--accent-${workspace.color})` : 'var(--muted-foreground)' }}
>
{workspace.shortLabel}
</span>
)
}
function WorkspaceHeader({
locale,
onSetDefaultWorkspace,
vault,
workspace,
}: Pick<WorkspaceRowProps, 'locale' | 'onSetDefaultWorkspace' | 'vault'> & {
workspace: ReturnType<typeof workspaceIdentityFromVault>
}) {
const handleSetDefaultWorkspace = () => {
onSetDefaultWorkspace?.(vault.path)
trackEvent('workspace_default_changed', { workspace_alias: workspace.alias })
}
return (
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<WorkspaceBadge workspace={workspace} />
<div className="min-w-0">
<div className="truncate text-sm font-medium text-foreground">{workspace.label}</div>
<div className="truncate text-[11px] text-muted-foreground">{workspace.path}</div>
</div>
</div>
<Button
type="button"
variant={workspace.defaultForNewNotes ? 'secondary' : 'ghost'}
size="xs"
onClick={handleSetDefaultWorkspace}
disabled={!onSetDefaultWorkspace || workspace.defaultForNewNotes}
data-testid={`workspace-default-${workspace.alias}`}
>
{workspace.defaultForNewNotes
? translate(locale, 'workspace.manager.default')
: translate(locale, 'workspace.manager.makeDefault')}
</Button>
</div>
)
}
function WorkspaceMountedControl({
canEdit,
locale,
onUpdateWorkspaceIdentity,
vault,
workspace,
}: Pick<WorkspaceRowProps, 'locale' | 'onUpdateWorkspaceIdentity' | 'vault'> & {
canEdit: boolean
workspace: ReturnType<typeof workspaceIdentityFromVault>
}) {
const handleMountedChange = (mounted: boolean) => {
onUpdateWorkspaceIdentity?.(vault.path, { mounted })
trackEvent('workspace_mount_changed', { workspace_alias: workspace.alias, mounted: mounted ? 1 : 0 })
}
return (
<label className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
<span>{translate(locale, 'workspace.manager.mounted')}</span>
<Switch
checked={workspace.mounted}
onCheckedChange={(mounted) => canEdit && handleMountedChange(mounted)}
disabled={!canEdit}
aria-label={translate(locale, 'workspace.manager.mounted')}
/>
</label>
)
}
function WorkspaceIdentityInputs({
canEdit,
locale,
onUpdateWorkspaceIdentity,
vault,
workspace,
}: Pick<WorkspaceRowProps, 'locale' | 'onUpdateWorkspaceIdentity' | 'vault'> & {
canEdit: boolean
workspace: ReturnType<typeof workspaceIdentityFromVault>
}) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_minmax(120px,0.5fr)] gap-2">
<Input
value={vault.label}
onChange={(event) => canEdit && onUpdateWorkspaceIdentity?.(vault.path, { label: event.target.value })}
aria-label={translate(locale, 'workspace.manager.label')}
disabled={!canEdit}
/>
<Input
value={vault.alias ?? workspace.alias}
onChange={(event) => canEdit && onUpdateWorkspaceIdentity?.(vault.path, { alias: event.target.value })}
aria-label={translate(locale, 'workspace.manager.alias')}
disabled={!canEdit}
/>
</div>
)
}
function WorkspaceColorPicker({
canEdit,
onUpdateWorkspaceIdentity,
vault,
}: Pick<WorkspaceRowProps, 'onUpdateWorkspaceIdentity' | 'vault'> & {
canEdit: boolean
}) {
return (
<div className="flex items-center gap-1">
{WORKSPACE_COLORS.map((color) => (
<WorkspaceColorButton
key={color}
color={color}
selected={(vault.color ?? null) === color}
onSelect={() => canEdit && onUpdateWorkspaceIdentity?.(vault.path, { color })}
/>
))}
</div>
)
}
function WorkspaceRow({
defaultWorkspacePath,
locale,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
vault,
}: WorkspaceRowProps) {
const workspace = workspaceIdentityFromVault(vault, { defaultWorkspacePath })
const canEdit = !!onUpdateWorkspaceIdentity && vault.path !== '' && !vault.managedDefault
return (
<div
className="grid gap-2 rounded-md border border-border p-3"
data-testid={`workspace-row-${workspace.alias}`}
>
<WorkspaceHeader locale={locale} onSetDefaultWorkspace={onSetDefaultWorkspace} vault={vault} workspace={workspace} />
<WorkspaceMountedControl canEdit={canEdit} locale={locale} onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity} vault={vault} workspace={workspace} />
<WorkspaceIdentityInputs canEdit={canEdit} locale={locale} onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity} vault={vault} workspace={workspace} />
<WorkspaceColorPicker canEdit={canEdit} onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity} vault={vault} />
</div>
)
}
function WorkspaceManager({
defaultWorkspacePath,
locale,
onOpenChange,
onSetDefaultWorkspace,
onUpdateWorkspaceIdentity,
open,
vaults,
vaultPath,
onSwitchVault,
onOpenLocalFolder,
onCreateEmptyVault,
onCloneVault,
onCloneGettingStarted,
onRemoveVault,
compact = false,
locale = 'en',
}: VaultMenuProps) {
}: WorkspaceManagerProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[82vh] overflow-y-auto sm:max-w-[720px]" data-testid="workspace-manager-dialog">
<DialogHeader>
<DialogTitle>{translate(locale, 'workspace.manager.title')}</DialogTitle>
<DialogDescription>{translate(locale, 'workspace.manager.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{vaults.map((vault) => (
<WorkspaceRow
key={vault.path}
defaultWorkspacePath={defaultWorkspacePath}
locale={locale}
onSetDefaultWorkspace={onSetDefaultWorkspace}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
vault={vault}
/>
))}
</div>
</DialogContent>
</Dialog>
)
}
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<HTMLDivElement>(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<VaultAction[]>(() => {
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 (
<div ref={menuRef} style={{ position: 'relative' }}>
<WorkspaceManager
defaultWorkspacePath={defaultWorkspacePath}
locale={locale}
onOpenChange={setManageOpen}
onSetDefaultWorkspace={onSetDefaultWorkspace}
onUpdateWorkspaceIdentity={onUpdateWorkspaceIdentity}
open={manageOpen}
vaults={vaults}
/>
<ActionTooltip copy={{ label: translate(locale, 'status.vault.switch') }} side="top">
<Button
type="button"

View File

@@ -1,5 +1,10 @@
export interface VaultOption {
label: string
path: string
alias?: string
color?: string | null
icon?: string | null
mounted?: boolean
managedDefault?: boolean
available?: boolean
}

View File

@@ -1,6 +1,7 @@
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultEntry } from '../types'
import { workspacePathForEntry } from '../utils/workspaces'
import { markNoteOpenTrace } from '../utils/noteOpenPerformance'
import { errorMessage, isActiveVaultUnavailableError } from '../utils/vaultErrors'
import { getNoteWindowParams, isNoteWindow } from '../utils/windowMode'
@@ -18,6 +19,7 @@ export interface NoteContentCacheEntry {
value: string | null
byteSize: number
identity: NoteContentIdentity | null
vaultPath?: string
requestState?: NoteContentRequestState
startRequest?: () => void
cancelRequest?: () => void
@@ -149,22 +151,23 @@ function retainResolvedNoteContent(entry: NoteContentCacheEntry, content: string
emitNoteContentResolved({ entry: sourceEntry, path: entry.path, content })
}
function getNoteContentCommandPayload(path: string): { path: string; vaultPath?: string } {
function getNoteContentCommandPayload(path: string, vaultPath?: string): { path: string; vaultPath?: string } {
if (vaultPath) return { path, vaultPath }
if (!isNoteWindow()) return { path }
const noteWindowParams = getNoteWindowParams()
return noteWindowParams ? { path, vaultPath: noteWindowParams.vaultPath } : { path }
}
function runGetNoteContentCommand(path: string): Promise<string> {
const commandPayload = getNoteContentCommandPayload(path)
function runGetNoteContentCommand(path: string, vaultPath?: string): Promise<string> {
const commandPayload = getNoteContentCommandPayload(path, vaultPath)
return isTauri()
? invoke<string>('get_note_content', commandPayload)
: mockInvoke<string>('get_note_content', commandPayload)
}
function getValidateNoteContentCommandPayload(path: string, content: string): { path: string; content: string; vaultPath?: string } {
return { ...getNoteContentCommandPayload(path), content }
function getValidateNoteContentCommandPayload(path: string, content: string, vaultPath?: string): { path: string; content: string; vaultPath?: string } {
return { ...getNoteContentCommandPayload(path, vaultPath), content }
}
function shouldReuseExistingRequest(existing: NoteContentCacheEntry, identity: NoteContentIdentity | null): boolean {
@@ -181,6 +184,7 @@ function markRequestSettled(entry: NoteContentCacheEntry, state: Extract<NoteCon
function createNoteContentRequest(target: string | VaultEntry): NoteContentCacheEntry {
const path = targetPath(target)
const sourceEntry = targetEntry(target)
const vaultPath = sourceEntry ? workspacePathForEntry(sourceEntry) : undefined
const identity = targetIdentity(target)
const cacheEntry: NoteContentCacheEntry = {
path,
@@ -188,6 +192,7 @@ function createNoteContentRequest(target: string | VaultEntry): NoteContentCache
value: null,
byteSize: 0,
identity,
vaultPath,
requestState: 'queued',
}
let started = false
@@ -199,7 +204,7 @@ function createNoteContentRequest(target: string | VaultEntry): NoteContentCache
if (started || settled) return
started = true
cacheEntry.requestState = 'running'
runGetNoteContentCommand(path)
runGetNoteContentCommand(path, vaultPath)
.then((content) => {
settled = true
markRequestSettled(cacheEntry, 'settled')
@@ -294,6 +299,7 @@ export function cacheNoteContent(path: string, content: string, entry?: VaultEnt
value: content,
byteSize,
identity: entry ? noteContentIdentity(entry) : null,
vaultPath: entry ? workspacePathForEntry(entry) : undefined,
})
emitNoteContentResolved({ entry: entry ?? null, path, content })
}
@@ -313,7 +319,7 @@ export function getCachedNoteContentEntry(path: string): NoteContentCacheEntry |
async function validateCachedNoteContent(entry: NoteContentCacheEntry): Promise<boolean> {
if (entry.value === null) return false
const payload = getValidateNoteContentCommandPayload(entry.path, entry.value)
const payload = getValidateNoteContentCommandPayload(entry.path, entry.value, entry.vaultPath)
return isTauri()
? invoke<boolean>('validate_note_content', payload)
: mockInvoke<boolean>('validate_note_content', payload)

View File

@@ -433,6 +433,7 @@ interface AppSaveDeps {
handleRenameFilename: (path: string, newFilenameStem: string, vaultPath: string, onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void) => Promise<void>
replaceEntry: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void
resolvedPath: string
writableVaultPaths?: readonly string[]
initialH1AutoRenameEnabled: boolean
onInternalVaultWrite?: (path: string) => void
locale?: AppLocale
@@ -791,7 +792,7 @@ export function useAppSave({
updateEntry, setTabs, handleSwitchTab, setToastMessage, loadModifiedFiles,
reloadViews, trackUnsaved, clearUnsaved, unsavedPaths, tabs, activeTabPath,
handleRenameNote, handleRenameFilename: handleRenameFilenameRaw, replaceEntry,
resolvedPath, initialH1AutoRenameEnabled, onInternalVaultWrite,
resolvedPath, writableVaultPaths, initialH1AutoRenameEnabled, onInternalVaultWrite,
locale = 'en',
}: AppSaveDeps) {
const contentChangeRef = useRef<(path: string, content: string) => void>(() => {})
@@ -808,7 +809,7 @@ export function useAppSave({
updateEntry, setTabs, setToastMessage, loadModifiedFiles, trackUnsaved,
clearUnsaved, onInternalVaultWrite, reloadViews, scheduleUntitledRename,
resolveCurrentPath, resolvePathBeforeSave, canPersist,
persistenceScope: resolvedPath,
persistenceScope: writableVaultPaths && writableVaultPaths.length > 0 ? writableVaultPaths : resolvedPath,
locale,
})
const replaceRenamedEntry = useReplaceRenamedEntry({ registerRenamedPath, replaceEntry })

View File

@@ -24,7 +24,7 @@ interface EditorSaveConfig {
/** False when editor state is present but no vault is available to receive writes. */
canPersist?: boolean
/** Clears pending debounced content when the persistence target changes. */
persistenceScope?: string
persistenceScope?: string | readonly string[]
disabledSaveMessage?: string
locale?: AppLocale
}
@@ -56,7 +56,7 @@ interface EditorSaveCommandsParams {
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
canPersistRef: MutableRefObject<boolean>
persistenceScopeRef: MutableRefObject<string | undefined>
persistenceScopeRef: MutableRefObject<string | readonly string[] | undefined>
persistenceScope?: EditorSaveConfig['persistenceScope']
disabledSaveMessage: string
t: Translator
@@ -133,7 +133,7 @@ async function persistResolvedContent({
saveNote: (path: string, content: string) => Promise<void>
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
persistenceScopeRef: MutableRefObject<string | undefined>
persistenceScopeRef: MutableRefObject<string | readonly string[] | undefined>
}): Promise<string | null> {
const targetPath = await resolvePersistPath(path, resolvePath, resolvePathBeforeSave)
if (!canWritePathToVault(targetPath, persistenceScopeRef.current ?? '')) return null
@@ -197,7 +197,7 @@ function usePendingContentFlush({
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
canPersistRef: MutableRefObject<boolean>
persistenceScopeRef: MutableRefObject<string | undefined>
persistenceScopeRef: MutableRefObject<string | readonly string[] | undefined>
}) {
return useCallback(async (pathFilter?: string): Promise<boolean> => {
const pending = pendingContentRef.current
@@ -268,7 +268,7 @@ async function persistUnsavedFallback({
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
persistenceScopeRef: MutableRefObject<string | undefined>
persistenceScopeRef: MutableRefObject<string | readonly string[] | undefined>
}): Promise<boolean> {
if (!unsavedFallback) return false
const targetPath = await persistResolvedContent({
@@ -323,7 +323,7 @@ async function persistImmediateSave({
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
persistenceScopeRef: MutableRefObject<string | undefined>
persistenceScopeRef: MutableRefObject<string | readonly string[] | undefined>
setToastMessage: EditorSaveConfig['setToastMessage']
onAfterSave: () => void
t: Translator
@@ -372,7 +372,7 @@ function useImmediateSaveCommands({
onNotePersisted?: EditorSaveConfig['onNotePersisted']
resolvePath?: EditorSaveConfig['resolvePath']
resolvePathBeforeSave?: EditorSaveConfig['resolvePathBeforeSave']
persistenceScopeRef: MutableRefObject<string | undefined>
persistenceScopeRef: MutableRefObject<string | readonly string[] | undefined>
canPersistRef: MutableRefObject<boolean>
disabledSaveMessage: string
t: Translator
@@ -439,7 +439,7 @@ function useContentChangeCommand({
flushPending: () => Promise<boolean>
onAfterSaveRef: MutableRefObject<() => void>
canPersistRef: MutableRefObject<boolean>
persistenceScopeRef: MutableRefObject<string | undefined>
persistenceScopeRef: MutableRefObject<string | readonly string[] | undefined>
resolvePath?: EditorSaveConfig['resolvePath']
t: Translator
}) {

View File

@@ -14,6 +14,7 @@ import {
} from './useNoteRename'
import { runFrontmatterAndApply, type FrontmatterOpOptions } from './frontmatterOps'
import { findByNotePath, notePathFilename, notePathsMatch } from '../utils/notePathIdentity'
import type { VaultOption } from '../components/status-bar/types'
export interface NoteActionsConfig {
addEntry: (entry: VaultEntry) => void
@@ -25,6 +26,8 @@ export interface NoteActionsConfig {
setToastMessage: (msg: string | null) => void
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
vaultPath: string
defaultWorkspacePath?: string | null
vaults?: readonly VaultOption[]
addPendingSave?: (path: string) => void
removePendingSave?: (path: string) => void
trackUnsaved?: (path: string) => void
@@ -128,12 +131,13 @@ async function notifyFrontmatterPersisted(config: NoteActionsConfig, key: string
interface NavigateWikilinkParams {
entries: VaultEntry[]
sourceEntry?: VaultEntry
target: string
selectNote: (entry: VaultEntry) => void
}
function navigateWikilink({ entries, target, selectNote }: NavigateWikilinkParams): void {
const found = resolveEntry(entries, target)
function navigateWikilink({ entries, sourceEntry, target, selectNote }: NavigateWikilinkParams): void {
const found = resolveEntry(entries, target, sourceEntry)
if (found) selectNote(found)
else console.warn(`Navigation target not found: ${target}`)
}
@@ -366,8 +370,13 @@ export function useNoteActions(config: NoteActionsConfig) {
)
const handleNavigateWikilink = useCallback(
(target: string) => navigateWikilink({ entries, target, selectNote: handleSelectNote }),
[entries, handleSelectNote],
(target: string) => navigateWikilink({
entries,
sourceEntry: tabMgmt.tabs.find((tab) => notePathsMatch(tab.entry.path, tabMgmt.activeTabPath))?.entry,
target,
selectNote: handleSelectNote,
}),
[entries, handleSelectNote, tabMgmt.activeTabPath, tabMgmt.tabs],
)
const runFrontmatterOp = useCallback(

View File

@@ -172,6 +172,44 @@ describe('resolveNewNote', () => {
expect(content).not.toContain('status:')
})
it('creates notes in the configured default workspace and keeps its identity', () => {
const { entry } = resolveNewNote({
title: 'Team Brief',
type: 'Note',
vaultPath: '/personal',
defaultWorkspacePath: '/team',
vaults: [
{ label: 'Personal', path: '/personal', alias: 'personal', available: true, mounted: true },
{ label: 'Team Notes', path: '/team', alias: 'team', color: 'green', available: true, mounted: true },
],
})
expect(entry.path).toBe('/team/team-brief.md')
expect(entry.workspace).toMatchObject({
label: 'Team Notes',
alias: 'team',
path: '/team',
color: 'green',
defaultForNewNotes: true,
})
})
it('falls back to the active workspace when the default workspace is unavailable', () => {
const { entry } = resolveNewNote({
title: 'Local Brief',
type: 'Note',
vaultPath: '/personal',
defaultWorkspacePath: '/team',
vaults: [
{ label: 'Personal', path: '/personal', alias: 'personal', available: true, mounted: true },
{ label: 'Team Notes', path: '/team', alias: 'team', available: false, mounted: true },
],
})
expect(entry.path).toBe('/personal/local-brief.md')
expect(entry.workspace?.alias).toBe('personal')
})
it('applies valued properties and relationships from the type entry to newly created instances', () => {
const typeEntry = makeEntry({
title: 'Book',
@@ -224,6 +262,20 @@ describe('resolveNewType', () => {
expect(entry.path).toBe('/vault/停智慧.md')
expect(entry.filename).toBe('停智慧.md')
})
it('creates type files in the configured default workspace', () => {
const { entry } = resolveNewType({
typeName: 'Decision',
vaultPath: '/personal',
defaultWorkspacePath: '/team',
vaults: [
{ label: 'Team Notes', path: '/team', alias: 'team', available: true, mounted: true },
],
})
expect(entry.path).toBe('/team/decision.md')
expect(entry.workspace?.alias).toBe('team')
})
})
describe('resolveTemplate', () => {

View File

@@ -9,6 +9,8 @@ import { cacheNoteContent } from './useTabManagement'
import { findByCollidingNotePath, joinVaultPath, notePathFilename } from '../utils/notePathIdentity'
import { canonicalFrontmatterKey } from '../utils/systemMetadata'
import { canonicalizeTypeName } from '../utils/vaultTypes'
import { labelFromWorkspacePath, workspaceIdentityFromVault } from '../utils/workspaces'
import type { VaultOption } from '../components/status-bar/types'
export interface NewEntryParams {
path: string
@@ -29,6 +31,27 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
}
}
function workspaceForVaultPath(vaultPath: string, vaults: readonly VaultOption[] = [], defaultWorkspacePath?: string | null) {
const configuredVault = vaults.find((vault) => vault.path === vaultPath)
return workspaceIdentityFromVault(configuredVault ?? {
label: labelFromWorkspacePath(vaultPath),
path: vaultPath,
available: true,
mounted: true,
}, { defaultWorkspacePath })
}
function resolveCreationVaultPath(
vaultPath: string,
defaultWorkspacePath?: string | null,
vaults: readonly VaultOption[] = [],
): string {
if (!defaultWorkspacePath) return vaultPath
const defaultVault = vaults.find((vault) => vault.path === defaultWorkspacePath)
if (!defaultVault) return defaultWorkspacePath
return defaultVault.available === false || defaultVault.mounted === false ? vaultPath : defaultVault.path
}
export { slugify }
/** Convert a filename slug to a human-readable title (hyphens → spaces, title case). */
@@ -226,14 +249,20 @@ export interface NewNoteParams {
title: string
type: string
vaultPath: string
defaultWorkspacePath?: string | null
vaults?: readonly VaultOption[]
template?: string | null
defaults?: TypeInstanceDefault[]
}
export function resolveNewNote({ title, type, vaultPath, template, defaults = [] }: NewNoteParams): { entry: VaultEntry; content: string } {
export function resolveNewNote({ title, type, vaultPath, defaultWorkspacePath, vaults = [], template, defaults = [] }: NewNoteParams): { entry: VaultEntry; content: string } {
const creationVaultPath = resolveCreationVaultPath(vaultPath, defaultWorkspacePath, vaults)
const slug = slugify(title)
const status = null
const entry = buildNewEntry({ path: joinVaultPath(vaultPath, `${slug}.md`), slug, title, type, status })
const entry = {
...buildNewEntry({ path: joinVaultPath(creationVaultPath, `${slug}.md`), slug, title, type, status }),
workspace: workspaceForVaultPath(creationVaultPath, vaults, defaultWorkspacePath),
}
return applyTypeDefaults({
entry,
content: buildNoteContent({ title, type, status, template, defaults }),
@@ -244,6 +273,8 @@ export function resolveNewNote({ title, type, vaultPath, template, defaults = []
export interface NewTypeParams {
typeName: string
vaultPath: string
defaultWorkspacePath?: string | null
vaults?: readonly VaultOption[]
}
const TYPE_CREATION_ALIASES = new Map<string, string>([
@@ -255,10 +286,14 @@ export function normalizeTypeCreationName(typeName: string): string {
return TYPE_CREATION_ALIASES.get(trimmed.toLowerCase()) ?? canonicalizeTypeName(trimmed) ?? trimmed
}
export function resolveNewType({ typeName, vaultPath }: NewTypeParams): { entry: VaultEntry; content: string } {
export function resolveNewType({ typeName, vaultPath, defaultWorkspacePath, vaults = [] }: NewTypeParams): { entry: VaultEntry; content: string } {
const normalizedTypeName = normalizeTypeCreationName(typeName)
const creationVaultPath = resolveCreationVaultPath(vaultPath, defaultWorkspacePath, vaults)
const slug = slugify(normalizedTypeName)
const entry = buildNewEntry({ path: joinVaultPath(vaultPath, `${slug}.md`), slug, title: normalizedTypeName, type: 'Type', status: null })
const entry = {
...buildNewEntry({ path: joinVaultPath(creationVaultPath, `${slug}.md`), slug, title: normalizedTypeName, type: 'Type', status: null }),
workspace: workspaceForVaultPath(creationVaultPath, vaults, defaultWorkspacePath),
}
return { entry, content: `---\ntype: Type\n---\n\n# ${normalizedTypeName}\n` }
}
@@ -331,14 +366,16 @@ function findEquivalentTypeEntry(entries: VaultEntry[], typeName: string): Vault
}
export function planNewNoteCreation({
defaultWorkspacePath,
entries,
title,
type,
vaultPath,
vaults,
template,
defaults,
}: NewNoteParams & { entries: VaultEntry[] }): NoteCreationPlan {
const resolved = resolveNewNote({ title, type, vaultPath, template, defaults })
const resolved = resolveNewNote({ title, type, vaultPath, defaultWorkspacePath, vaults, template, defaults })
const collision = findPathCollision(entries, resolved.entry.path)
if (collision) {
return {
@@ -350,14 +387,16 @@ export function planNewNoteCreation({
}
export function planNewTypeCreation({
defaultWorkspacePath,
entries,
typeName,
vaultPath,
vaults,
}: NewTypeParams & { entries: VaultEntry[] }): TypeCreationPlan {
const existingType = findEquivalentTypeEntry(entries, typeName)
if (existingType) return { status: 'existing', entry: existingType }
const resolved = resolveNewType({ typeName, vaultPath })
const resolved = resolveNewType({ typeName, vaultPath, defaultWorkspacePath, vaults })
const collision = findPathCollision(entries, resolved.entry.path)
if (collision) {
return {
@@ -454,8 +493,10 @@ type PersistResolvedEntryFn = (
) => Promise<void>
interface CreationDeps {
defaultWorkspacePath?: string | null
entries: VaultEntry[]
vaultPath: string
vaults?: readonly VaultOption[]
setToastMessage: (msg: string | null) => void
persistResolvedEntry: PersistResolvedEntryFn
}
@@ -468,16 +509,18 @@ interface NoteCreationRequest extends CreationDeps {
async function createNamedNote({
entries,
defaultWorkspacePath,
title,
type,
vaultPath,
vaults,
setToastMessage,
persistResolvedEntry,
creationPath,
}: NoteCreationRequest): Promise<boolean> {
const template = resolveTemplate({ entries, typeName: type })
const defaults = resolveTypeInstanceDefaults({ entries, typeName: type })
const plan = planNewNoteCreation({ entries, title, type, vaultPath, template, defaults })
const plan = planNewNoteCreation({ entries, title, type, vaultPath, defaultWorkspacePath, vaults, template, defaults })
if (plan.status === 'blocked') {
setToastMessage(plan.message)
return false
@@ -501,12 +544,14 @@ interface TypeCreationRequest extends CreationDeps {
async function createTypeFromName({
entries,
defaultWorkspacePath,
typeName,
vaultPath,
vaults,
setToastMessage,
persistResolvedEntry,
}: TypeCreationRequest): Promise<boolean> {
const plan = planNewTypeCreation({ entries, typeName, vaultPath })
const plan = planNewTypeCreation({ entries, typeName, vaultPath, defaultWorkspacePath, vaults })
if (plan.status === 'existing') {
setToastMessage(`Type "${plan.entry.title}" already exists`)
return false
@@ -534,12 +579,14 @@ async function createTypeFromName({
async function createTypeSilently({
entries,
defaultWorkspacePath,
typeName,
vaultPath,
vaults,
setToastMessage,
persistResolvedEntry,
}: TypeCreationRequest): Promise<VaultEntry> {
const plan = planNewTypeCreation({ entries, typeName, vaultPath })
const plan = planNewTypeCreation({ entries, typeName, vaultPath, defaultWorkspacePath, vaults })
if (plan.status === 'existing') return plan.entry
if (plan.status === 'blocked') {
setToastMessage(plan.message)
@@ -564,8 +611,10 @@ async function createTypeSilently({
interface ImmediateCreateDeps {
addPendingSave?: (path: string) => void
defaultWorkspacePath?: string | null
entries: VaultEntry[]
vaultPath: string
vaults?: readonly VaultOption[]
pendingSlugs: Set<string>
openTabWithContent: (entry: VaultEntry, content: string) => void
addEntry: (entry: VaultEntry) => void
@@ -580,8 +629,10 @@ interface ImmediateCreateRequest {
interface ImmediateCreateQueueConfig {
addPendingSave?: (path: string) => void
defaultWorkspacePath?: string | null
entries: VaultEntry[]
vaultPath: string
vaults?: readonly VaultOption[]
addEntry: (entry: VaultEntry) => void
openTabWithContent: (entry: VaultEntry, content: string) => void
onNewNotePersisted?: (path: string) => void
@@ -633,7 +684,11 @@ async function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): Pr
const template = resolveTemplate({ entries: deps.entries, typeName: noteType })
const defaults = resolveTypeInstanceDefaults({ entries: deps.entries, typeName: noteType })
const status = null
const entry = buildNewEntry({ path: joinVaultPath(deps.vaultPath, `${slug}.md`), slug, title, type: noteType, status })
const creationVaultPath = resolveCreationVaultPath(deps.vaultPath, deps.defaultWorkspacePath, deps.vaults)
const entry = {
...buildNewEntry({ path: joinVaultPath(creationVaultPath, `${slug}.md`), slug, title, type: noteType, status }),
workspace: workspaceForVaultPath(creationVaultPath, deps.vaults, deps.defaultWorkspacePath),
}
const resolved = applyTypeDefaults({
entry,
content: buildNoteContent({ title: null, type: noteType, status, template, initialEmptyHeading: true, defaults }),
@@ -662,8 +717,10 @@ function useLatestImmediateCreateDeps(
pendingSlugsRef: MutableRefObject<Set<string>>,
) {
const {
defaultWorkspacePath,
entries,
vaultPath,
vaults,
openTabWithContent,
addEntry,
addPendingSave,
@@ -675,7 +732,9 @@ function useLatestImmediateCreateDeps(
const syncDeps = useCallback(() => {
latestDepsRef.current = {
entries,
defaultWorkspacePath,
vaultPath,
vaults,
pendingSlugs: pendingSlugsRef.current,
openTabWithContent,
addEntry,
@@ -686,7 +745,9 @@ function useLatestImmediateCreateDeps(
}
}, [
entries,
defaultWorkspacePath,
vaultPath,
vaults,
openTabWithContent,
addEntry,
addPendingSave,
@@ -769,6 +830,8 @@ export interface NoteCreationConfig {
entries: VaultEntry[]
setToastMessage: (msg: string | null) => void
vaultPath: string
defaultWorkspacePath?: string | null
vaults?: readonly VaultOption[]
addPendingSave?: (path: string) => void
removePendingSave?: (path: string) => void
trackUnsaved?: (path: string) => void
@@ -787,11 +850,13 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
const {
addEntry,
removeEntry,
defaultWorkspacePath,
entries,
setToastMessage,
addPendingSave,
removePendingSave,
vaultPath,
vaults,
onNewNotePersisted,
onTypeStateChanged,
} = config
@@ -819,24 +884,26 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
}, [openTabWithContent, addEntry, addPendingSave, removePendingSave, onNewNotePersisted, onTypeStateChanged, removeEntry])
const handleCreateNote = useCallback((title: string, type: string): Promise<boolean> =>
createNamedNote({ entries, vaultPath, setToastMessage, persistResolvedEntry, title, type, creationPath: 'plus_button' }),
[entries, vaultPath, setToastMessage, persistResolvedEntry])
createNamedNote({ entries, vaultPath, defaultWorkspacePath, vaults, setToastMessage, persistResolvedEntry, title, type, creationPath: 'plus_button' }),
[entries, vaultPath, defaultWorkspacePath, vaults, setToastMessage, persistResolvedEntry])
const handleCreateType = useCallback((typeName: string): Promise<boolean> =>
createTypeFromName({ entries, vaultPath, setToastMessage, persistResolvedEntry, typeName }),
[entries, vaultPath, setToastMessage, persistResolvedEntry])
createTypeFromName({ entries, vaultPath, defaultWorkspacePath, vaults, setToastMessage, persistResolvedEntry, typeName }),
[entries, vaultPath, defaultWorkspacePath, vaults, setToastMessage, persistResolvedEntry])
const createTypeEntrySilent = useCallback((typeName: string): Promise<VaultEntry> =>
createTypeSilently({ entries, vaultPath, setToastMessage, persistResolvedEntry, typeName }),
[entries, vaultPath, setToastMessage, persistResolvedEntry])
createTypeSilently({ entries, vaultPath, defaultWorkspacePath, vaults, setToastMessage, persistResolvedEntry, typeName }),
[entries, vaultPath, defaultWorkspacePath, vaults, setToastMessage, persistResolvedEntry])
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> =>
createNamedNote({ entries, vaultPath, setToastMessage, persistResolvedEntry, title, type: 'Note' }),
[entries, vaultPath, setToastMessage, persistResolvedEntry])
createNamedNote({ entries, vaultPath, defaultWorkspacePath, vaults, setToastMessage, persistResolvedEntry, title, type: 'Note' }),
[entries, vaultPath, defaultWorkspacePath, vaults, setToastMessage, persistResolvedEntry])
const handleCreateNoteImmediate = useImmediateCreateQueue({
entries,
vaultPath,
defaultWorkspacePath,
vaults,
addEntry,
addPendingSave,
openTabWithContent,

View File

@@ -4,6 +4,7 @@ import { fuzzyMatch, bestSearchRank } from '../utils/fuzzyMatch'
import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors'
import { getTypeIcon } from '../components/NoteItem'
import type { NoteSearchResultItem } from '../components/NoteSearchList'
import { workspaceDisplayPrefix } from '../utils/workspaces'
const DEFAULT_MAX_RESULTS = 20
@@ -11,12 +12,12 @@ export interface NoteSearchResult extends NoteSearchResultItem {
entry: VaultEntry
}
function toResult(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>): NoteSearchResult {
function toResult(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>, showWorkspace: boolean): NoteSearchResult {
const noteType = e.isA || undefined
const te = typeEntryMap[e.isA ?? '']
return {
entry: e,
title: e.title,
title: `${showWorkspace ? workspaceDisplayPrefix(e) ?? '' : ''}${e.title}`,
noteIcon: e.icon,
noteType,
typeColor: noteType ? getTypeColor(e.isA, te?.color) : undefined,
@@ -36,9 +37,13 @@ export function useNoteSearch(entries: VaultEntry[], query: string, maxResults =
() => entries.filter((e) => !SEARCH_EXCLUDED_TYPES.has(e.isA ?? '')),
[entries],
)
const showWorkspace = useMemo(
() => new Set(entries.map((entry) => entry.workspace?.alias).filter(Boolean)).size > 1,
[entries],
)
const results: NoteSearchResult[] = useMemo(() => {
const mapResult = (e: VaultEntry) => toResult(e, typeEntryMap)
const mapResult = (e: VaultEntry) => toResult(e, typeEntryMap, showWorkspace)
if (!query.trim()) {
return [...searchableEntries]
.sort((a, b) => (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0))
@@ -55,7 +60,7 @@ export function useNoteSearch(entries: VaultEntry[], query: string, maxResults =
.sort((a, b) => a.rank - b.rank || b.score - a.score)
.slice(0, maxResults)
.map((r) => mapResult(r.entry))
}, [searchableEntries, query, maxResults, typeEntryMap])
}, [searchableEntries, query, maxResults, typeEntryMap, showWorkspace])
useEffect(() => {
setSelectedIndex(0) // eslint-disable-line react-hooks/set-state-in-effect -- reset on query change

View File

@@ -66,7 +66,7 @@ function useGitignoredVisibilitySearchRefresh({
}, [active, performSearch, query])
}
export function useUnifiedSearch(vaultPath: string, active: boolean) {
export function useUnifiedSearch(vaultPath: string | string[], active: boolean) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [selectedIndex, setSelectedIndex] = useState(0)
@@ -98,10 +98,13 @@ export function useUnifiedSearch(vaultPath: string, active: boolean) {
const gen = searchGenRef.current
setLoading(true)
try {
const response = await searchCall({ vaultPath, query: q, mode: 'keyword', limit: 20 })
const paths = Array.isArray(vaultPath) ? vaultPath : [vaultPath]
const responses = await Promise.all(paths
.filter((path) => path.trim().length > 0)
.map((path) => searchCall({ vaultPath: path, query: q, mode: 'keyword', limit: 20 })))
if (gen !== searchGenRef.current) return
setResults(mapResults(response.results))
setElapsedMs(response.elapsed_ms)
setResults(mapResults(responses.flatMap((response) => response.results)).slice(0, 20))
setElapsedMs(responses.reduce((sum, response) => sum + response.elapsed_ms, 0))
setSelectedIndex(0)
} catch {
if (gen !== searchGenRef.current) return

View File

@@ -49,6 +49,7 @@ function defaultMockInvoke(cmd: string, args?: Record<string, unknown>) {
let mockIsTauri = false
const backendInvokeFn = vi.fn(defaultMockInvoke)
const EMPTY_ARRAY_COMMANDS = new Set(['get_modified_files', 'list_vault_folders', 'list_views'])
function isVaultLoadCommand(cmd: string) {
return cmd === 'list_vault' || cmd === 'reload_vault'
@@ -141,6 +142,23 @@ async function enableTauriMode() {
)
}
function buildMountedWorkspaceLoadMock() {
return ((cmd: string, args?: Record<string, unknown>) => {
if (isVaultLoadCommand(cmd)) {
const path = args?.path
return Promise.resolve([{
...mockEntries[0],
path: `${path}/note/hello.md`,
title: path === '/team' ? 'Team Hello' : 'Personal Hello',
}])
}
if (EMPTY_ARRAY_COMMANDS.has(cmd)) {
return Promise.resolve([])
}
return Promise.resolve(null)
}) as typeof defaultMockInvoke
}
describe('useVaultLoader', () => {
beforeEach(() => {
mockIsTauri = false
@@ -154,6 +172,21 @@ describe('useVaultLoader', () => {
expect(result.current.entries[0].title).toBe('Hello')
})
it('loads entries from every mounted workspace and annotates provenance', async () => {
backendInvokeFn.mockImplementation(buildMountedWorkspaceLoadMock())
const vaults = [
{ label: 'Personal', path: '/personal', alias: 'personal', available: true, mounted: true },
{ label: 'Team', path: '/team', alias: 'team', available: true, mounted: true },
]
const { result } = renderHook(() => useVaultLoader('/personal', vaults, '/personal'))
await waitForEntries(result, 2)
expect(result.current.entries.map((entry) => entry.workspace?.alias).sort()).toEqual(['personal', 'team'])
expect(result.current.entries.find((entry) => entry.workspace?.alias === 'team')?.workspace?.defaultForNewNotes).toBe(false)
})
it('normalizes missing entry and view string metadata from vault load', async () => {
backendInvokeFn.mockImplementation(((cmd: string) => {
if (isVaultLoadCommand(cmd)) {

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import type { VaultEntry, FolderNode, GitCommit, ModifiedFile, NoteStatus, GitPushResult, ViewFile } from '../types'
import type { VaultOption } from '../components/status-bar/types'
import {
GITIGNORED_VISIBILITY_CHANGED_EVENT,
notifyGitignoredVisibilityApplied,
@@ -22,6 +23,7 @@ import { useUnavailableVaultState } from './useUnavailableVaultState'
import { resetVaultState } from './vaultStateReset'
interface InitialVaultLoadStateOptions {
defaultWorkspacePath?: string | null
handleVaultAvailable: (path: string) => void
path: string
handleVaultUnavailable: (path: string) => void
@@ -30,6 +32,7 @@ interface InitialVaultLoadStateOptions {
setFolders: (folders: FolderNode[]) => void
setIsLoading: (isLoading: boolean) => void
setViews: (views: ViewFile[]) => void
vaults?: VaultOption[]
}
interface InitialVaultChromeOptions extends Pick<
@@ -62,7 +65,7 @@ async function loadInitialVaultEntriesState(options: Pick<
const { handleVaultAvailable, handleVaultUnavailable, isCurrentVaultPath, path, setEntries } = options
try {
const { entries } = await loadVaultData({ vaultPath: path })
const { entries } = await loadVaultData({ vaultPath: path, vaults: options.vaults, defaultWorkspacePath: options.defaultWorkspacePath })
if (isCurrentVaultPath(path)) {
handleVaultAvailable(path)
setEntries(entries)
@@ -235,9 +238,11 @@ export function resolveNoteStatus({
}
interface InitialVaultLoadOptions {
defaultWorkspacePath?: string | null
handleVaultAvailable: (path: string) => void
handleVaultUnavailable: (path: string) => void
vaultPath: string
vaults?: VaultOption[]
tracker: ReturnType<typeof useNewNoteTracker>
unsaved: ReturnType<typeof useUnsavedTracker>
isCurrentVaultPath: (path: string) => boolean
@@ -265,6 +270,8 @@ function useInitialVaultLoad(options: InitialVaultLoadOptions) {
setModifiedFiles,
setModifiedFilesError,
setViews,
vaults,
defaultWorkspacePath,
} = options
useEffect(() => {
@@ -290,6 +297,8 @@ function useInitialVaultLoad(options: InitialVaultLoadOptions) {
path,
handleVaultUnavailable,
isCurrentVaultPath: (candidate) => !cancelled && isCurrentVaultPath(candidate),
vaults,
defaultWorkspacePath,
setEntries,
setFolders,
setIsLoading,
@@ -310,6 +319,8 @@ function useInitialVaultLoad(options: InitialVaultLoadOptions) {
setModifiedFiles,
setModifiedFilesError,
setViews,
vaults,
defaultWorkspacePath,
])
}
@@ -434,6 +445,7 @@ function useGitLoaders(vaultPath: string) {
}
interface VaultReloadOptions {
defaultWorkspacePath?: string | null
handleVaultAvailable: (path: string) => void
handleVaultUnavailable: (path: string) => void
vaultPath: string
@@ -442,6 +454,7 @@ interface VaultReloadOptions {
setEntries: (entries: VaultEntry[]) => void
setFolders: (folders: FolderNode[]) => void
setViews: (views: ViewFile[]) => void
vaults?: VaultOption[]
}
interface EntryReloadOptions extends VaultReloadOptions {
@@ -496,6 +509,8 @@ function useEntryReload({
loadModifiedFiles,
setEntries,
vaultPath,
vaults,
defaultWorkspacePath,
}: EntryReloadOptions) {
const runEntryReload = useCallback(async () => {
const path = vaultPath
@@ -503,7 +518,7 @@ function useEntryReload({
clearPrefetchCache()
beginReload()
try {
const entries = await reloadVaultEntries({ vaultPath: path })
const entries = await reloadVaultEntries({ vaultPath: path, vaults, defaultWorkspacePath })
if (!isCurrentVaultPath(path)) return [] as VaultEntry[]
handleVaultAvailable(path)
setEntries(entries)
@@ -516,7 +531,7 @@ function useEntryReload({
} finally {
finishReload()
}
}, [handleVaultAvailable, handleVaultUnavailable, vaultPath, beginReload, finishReload, loadModifiedFiles, isCurrentVaultPath, setEntries])
}, [handleVaultAvailable, handleVaultUnavailable, vaultPath, vaults, defaultWorkspacePath, beginReload, finishReload, loadModifiedFiles, isCurrentVaultPath, setEntries])
return useCoalescedAsyncTask(runEntryReload)
}
@@ -629,7 +644,7 @@ function useVaultUnavailable(vaultPath: string, state: ReturnType<typeof useVaul
})
}
export function useVaultLoader(vaultPath: string) {
export function useVaultLoader(vaultPath: string, vaults?: VaultOption[], defaultWorkspacePath?: string | null) {
const state = useVaultState(vaultPath)
const { entries, folders, isCurrentVaultPath, isLoading, modified, pendingSave, setEntries, setFolders, setIsLoading, setViews, tracker, unsaved, views } = state
const {
@@ -645,7 +660,9 @@ export function useVaultLoader(vaultPath: string) {
const vaultReloads = useVaultReloads({
handleVaultAvailable: unavailableVault.markVaultAvailable,
handleVaultUnavailable: unavailableVault.markVaultUnavailable,
defaultWorkspacePath,
vaultPath,
vaults,
isCurrentVaultPath,
loadModifiedFiles,
setEntries,
@@ -658,6 +675,8 @@ export function useVaultLoader(vaultPath: string) {
handleVaultAvailable: unavailableVault.markVaultAvailable,
handleVaultUnavailable: unavailableVault.markVaultUnavailable,
vaultPath,
vaults,
defaultWorkspacePath,
tracker,
unsaved,
isCurrentVaultPath,

View File

@@ -99,7 +99,7 @@ describe('useVaultSwitcher', () => {
it('loads the default vault when the resolved path exists', async () => {
const { result } = await renderLoadedVaultSwitcher()
expect(result.current.allVaults).toEqual([{ label: 'Getting Started', path: expectedDefaultVaultPath }])
expect(result.current.allVaults).toEqual([{ label: 'Getting Started', path: expectedDefaultVaultPath, managedDefault: true }])
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
})
@@ -169,8 +169,16 @@ describe('useVaultSwitcher', () => {
expect(result.current.vaultPath).toBe('/selected/vault')
expect(result.current.selectedVaultPath).toBe('/selected/vault')
expect(mockVaultListStore).toEqual({
vaults: [{ label: 'Selected Vault', path: '/selected/vault' }],
vaults: [{
label: 'Selected Vault',
path: '/selected/vault',
alias: null,
color: null,
icon: null,
mounted: true,
}],
active_vault: '/selected/vault',
default_workspace_path: '/selected/vault',
hidden_defaults: [],
})
})
@@ -188,10 +196,11 @@ describe('useVaultSwitcher', () => {
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
expect(result.current.selectedVaultPath).toBe(expectedDefaultVaultPath)
expect(result.current.allVaults).toEqual([{ label: 'Getting Started', path: expectedDefaultVaultPath }])
expect(result.current.allVaults).toEqual([{ label: 'Getting Started', path: expectedDefaultVaultPath, managedDefault: true }])
expect(mockVaultListStore).toEqual({
vaults: [],
active_vault: expectedDefaultVaultPath,
default_workspace_path: expectedDefaultVaultPath,
hidden_defaults: [],
})
})
@@ -295,13 +304,29 @@ describe('useVaultSwitcher', () => {
const { result } = await renderLoadedVaultSwitcher()
expect(result.current.allVaults).toEqual([{ label: 'Work', path: '/work/vault', available: true }])
expect(result.current.allVaults).toEqual([{
label: 'Work',
path: '/work/vault',
alias: undefined,
color: null,
icon: null,
mounted: true,
available: true,
}])
expect(result.current.vaultPath).toBe('/work/vault')
await waitFor(() => {
expect(mockVaultListStore).toEqual({
vaults: [{ label: 'Work', path: '/work/vault' }],
vaults: [{
label: 'Work',
path: '/work/vault',
alias: null,
color: null,
icon: null,
mounted: true,
}],
active_vault: '/work/vault',
default_workspace_path: '/work/vault',
hidden_defaults: [],
})
})
@@ -324,6 +349,7 @@ describe('useVaultSwitcher', () => {
expect(mockVaultListStore).toEqual({
vaults: [],
active_vault: null,
default_workspace_path: null,
hidden_defaults: [],
})
})

View File

@@ -6,6 +6,8 @@ import { formatFolderPickerActionError, pickFolder } from '../utils/vault-dialog
import { loadVaultList, saveVaultList } from '../utils/vaultListStore'
import type { VaultOption } from '../components/StatusBar'
import { trackEvent } from '../lib/telemetry'
import { useWorkspaceIdentityActions } from './useWorkspaceIdentityActions'
import { sanitizeDefaultWorkspacePath } from './vaultSwitcherSanitization'
export type { PersistedVaultList } from '../utils/vaultListStore'
@@ -29,12 +31,14 @@ interface UseVaultSwitcherOptions {
interface PersistedVaultState {
defaultAvailable: boolean
defaultPath: string
defaultWorkspacePath: string | null
extraVaults: VaultOption[]
hiddenDefaults: string[]
lastPersistedSnapshotRef: MutableRefObject<string | null>
loaded: boolean
selectedVaultPath: string | null
setDefaultAvailable: Dispatch<SetStateAction<boolean>>
setDefaultWorkspacePath: Dispatch<SetStateAction<string | null>>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
@@ -51,6 +55,7 @@ interface VaultCollections {
interface PersistedVaultStore {
defaultAvailable: boolean
defaultPath: string
defaultWorkspacePath: string | null
extraVaults: VaultOption[]
hiddenDefaults: string[]
lastPersistedSnapshotRef: MutableRefObject<string | null>
@@ -58,6 +63,7 @@ interface PersistedVaultStore {
selectedVaultPath: string | null
setDefaultAvailable: Dispatch<SetStateAction<boolean>>
setDefaultPath: Dispatch<SetStateAction<string>>
setDefaultWorkspacePath: Dispatch<SetStateAction<string | null>>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setLoaded: Dispatch<SetStateAction<boolean>>
@@ -99,9 +105,11 @@ interface RemainingVaultOptions {
}
interface RemoveVaultStateOptions extends RemainingVaultOptions {
defaultWorkspacePath: string | null
selectedVaultPath: string | null
onSwitchRef: MutableRefObject<() => void>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setDefaultWorkspacePath: Dispatch<SetStateAction<string | null>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
@@ -109,11 +117,13 @@ interface RemoveVaultStateOptions extends RemainingVaultOptions {
}
interface RemoveVaultActionOptions {
defaultWorkspacePath: string | null
defaultVaults: VaultOption[]
extraVaults: VaultOption[]
hiddenDefaults: string[]
onSwitchRef: MutableRefObject<() => void>
onToastRef: MutableRefObject<(msg: string) => void>
setDefaultWorkspacePath: Dispatch<SetStateAction<string | null>>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
@@ -138,11 +148,20 @@ function serializePersistedVaultSnapshot(
vaults: VaultOption[],
activeVault: string | null,
hiddenDefaults: string[],
defaultWorkspacePath: string | null,
): string {
return JSON.stringify({
activeVault,
defaultWorkspacePath,
hiddenDefaults,
vaults: vaults.map(({ label, path }) => ({ label, path })),
vaults: vaults.map(({ label, path, alias, color, icon, mounted }) => ({
label,
path,
alias: alias ?? null,
color: color ?? null,
icon: icon ?? null,
mounted: mounted !== false,
})),
})
}
@@ -202,9 +221,9 @@ async function loadInitialVaultState() {
loadVaultList(),
resolveDefaultPath(),
])
const { vaults, activeVault, hiddenDefaults } = vaultListResult.status === 'fulfilled'
const { vaults, activeVault, defaultWorkspacePath, hiddenDefaults } = vaultListResult.status === 'fulfilled'
? vaultListResult.value
: { vaults: [], activeVault: null, hiddenDefaults: [] }
: { vaults: [], activeVault: null, defaultWorkspacePath: null, hiddenDefaults: [] }
const resolvedDefaultPath = defaultPathResult.status === 'fulfilled'
? defaultPathResult.value
: ''
@@ -221,10 +240,18 @@ async function loadInitialVaultState() {
resolvedDefaultPath,
vaults,
})
const persistedSnapshot = serializePersistedVaultSnapshot(vaults, activeVault, hiddenDefaults)
const persistedSnapshot = serializePersistedVaultSnapshot(vaults, activeVault, hiddenDefaults, defaultWorkspacePath)
const sanitizedDefaultWorkspacePath = sanitizeDefaultWorkspacePath({
activeVault: sanitizedState.activeVault,
defaultAvailable,
defaultWorkspacePath,
resolvedDefaultPath,
vaults: sanitizedState.vaults,
})
return {
...sanitizedState,
defaultWorkspacePath: sanitizedDefaultWorkspacePath,
persistedSnapshot,
}
}
@@ -306,7 +333,7 @@ function buildDefaultVaults({
return []
}
return [{ label: GETTING_STARTED_LABEL, path: defaultPath }]
return [{ label: GETTING_STARTED_LABEL, path: defaultPath, managedDefault: true }]
}
function buildVisibleDefaultVaults({
@@ -422,6 +449,7 @@ function useLoadPersistedVaultState(
lastPersistedSnapshotRef,
setDefaultAvailable,
setDefaultPath,
setDefaultWorkspacePath,
setExtraVaults,
setHiddenDefaults,
setLoaded,
@@ -433,12 +461,13 @@ function useLoadPersistedVaultState(
let cancelled = false
loadInitialVaultState()
.then(({ activeVault, defaultAvailable, hiddenDefaults: hidden, persistedSnapshot, resolvedDefaultPath, vaults }) => {
.then(({ activeVault, defaultAvailable, defaultWorkspacePath, hiddenDefaults: hidden, persistedSnapshot, resolvedDefaultPath, vaults }) => {
if (cancelled) return
lastPersistedSnapshotRef.current = persistedSnapshot
setExtraVaults(vaults)
setHiddenDefaults(hidden)
setDefaultWorkspacePath(defaultWorkspacePath ?? activeVault ?? null)
applyResolvedDefaultPath({
defaultAvailable,
resolvedDefaultPath,
@@ -460,34 +489,35 @@ function useLoadPersistedVaultState(
})
return () => { cancelled = true }
}, [lastPersistedSnapshotRef, onSwitchRef, setDefaultAvailable, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setSelectedVaultPath, setVaultPath])
}, [lastPersistedSnapshotRef, onSwitchRef, setDefaultAvailable, setDefaultPath, setDefaultWorkspacePath, setExtraVaults, setHiddenDefaults, setLoaded, setSelectedVaultPath, setVaultPath])
}
function usePersistedVaultStorage(store: PersistedVaultStore) {
const { extraVaults, hiddenDefaults, lastPersistedSnapshotRef, loaded, selectedVaultPath } = store
const { defaultWorkspacePath, extraVaults, hiddenDefaults, lastPersistedSnapshotRef, loaded, selectedVaultPath } = store
useEffect(() => {
if (!loaded) return
const snapshot = serializePersistedVaultSnapshot(extraVaults, selectedVaultPath, hiddenDefaults)
const snapshot = serializePersistedVaultSnapshot(extraVaults, selectedVaultPath, hiddenDefaults, defaultWorkspacePath)
if (lastPersistedSnapshotRef.current === snapshot) {
return
}
saveVaultList(extraVaults, selectedVaultPath, hiddenDefaults)
saveVaultList(extraVaults, selectedVaultPath, hiddenDefaults, defaultWorkspacePath)
.then(() => {
lastPersistedSnapshotRef.current = snapshot
})
.catch(err => {
console.warn('Failed to persist vault list:', err)
})
}, [extraVaults, hiddenDefaults, lastPersistedSnapshotRef, loaded, selectedVaultPath])
}, [defaultWorkspacePath, extraVaults, hiddenDefaults, lastPersistedSnapshotRef, loaded, selectedVaultPath])
}
function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): PersistedVaultState {
const [vaultPath, setVaultPath] = useState(STATIC_DEFAULT_PATH)
const [selectedVaultPath, setSelectedVaultPath] = useState<string | null>(null)
const [defaultWorkspacePath, setDefaultWorkspacePath] = useState<string | null>(null)
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
const [hiddenDefaults, setHiddenDefaults] = useState<string[]>([])
const [defaultAvailable, setDefaultAvailable] = useState(false)
@@ -498,6 +528,7 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
const store: PersistedVaultStore = {
defaultAvailable,
defaultPath,
defaultWorkspacePath,
extraVaults,
hiddenDefaults,
lastPersistedSnapshotRef,
@@ -505,6 +536,7 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
selectedVaultPath,
setDefaultAvailable,
setDefaultPath,
setDefaultWorkspacePath,
setExtraVaults,
setHiddenDefaults,
setLoaded,
@@ -524,6 +556,7 @@ function usePersistedVaultState(onSwitchRef: MutableRefObject<() => void>): Pers
lastPersistedSnapshotRef,
loaded,
selectedVaultPath,
setDefaultWorkspacePath,
setDefaultAvailable,
setExtraVaults,
setHiddenDefaults,
@@ -598,7 +631,7 @@ function addVaultToList({
}) {
setExtraVaults(previousVaults => {
const exists = previousVaults.some(vault => vault.path === path)
return exists ? previousVaults : [...previousVaults, { label, path, available: true }]
return exists ? previousVaults : [...previousVaults, { label, path, available: true, mounted: true }]
})
}
@@ -609,7 +642,7 @@ function upsertAvailableVaultOption(
): VaultOption[] {
const existingVault = extraVaults.find((vault) => vault.path === path)
if (!existingVault) {
return [...extraVaults, { label, path, available: true }]
return [...extraVaults, { label, path, available: true, mounted: true }]
}
return extraVaults.map((vault) => (
@@ -649,11 +682,13 @@ function buildRegisteredVaultSelection({
}
async function persistRegisteredVaultSelection({
defaultWorkspacePath,
hiddenDefaults,
lastPersistedSnapshotRef,
selectedVaultPath,
vaults,
}: {
defaultWorkspacePath: string | null
hiddenDefaults: string[]
lastPersistedSnapshotRef: MutableRefObject<string | null>
selectedVaultPath: string
@@ -663,8 +698,9 @@ async function persistRegisteredVaultSelection({
vaults,
selectedVaultPath,
hiddenDefaults,
defaultWorkspacePath,
)
await saveVaultList(vaults, selectedVaultPath, hiddenDefaults)
await saveVaultList(vaults, selectedVaultPath, hiddenDefaults, defaultWorkspacePath)
lastPersistedSnapshotRef.current = nextSnapshot
}
@@ -720,6 +756,22 @@ async function ensureVaultCanBeRegistered(path: string): Promise<void> {
if (!exists) throw new Error('Selected folder is not available')
}
interface RegisterVaultSelectionActionDeps {
defaultAvailable: boolean
defaultPath: string
defaultWorkspacePath: string | null
extraVaults: VaultOption[]
hiddenDefaults: string[]
lastPersistedSnapshotRef: MutableRefObject<string | null>
onSwitchRef: MutableRefObject<() => void>
setDefaultAvailable: Dispatch<SetStateAction<boolean>>
setDefaultWorkspacePath: Dispatch<SetStateAction<string | null>>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
}
function listRemainingVaults({
defaultVaults,
extraVaults,
@@ -736,6 +788,7 @@ function listRemainingVaults({
}
function removeVaultFromState({
defaultWorkspacePath,
defaultVaults,
extraVaults,
hiddenDefaults,
@@ -743,6 +796,7 @@ function removeVaultFromState({
onSwitchRef,
removedPath,
setExtraVaults,
setDefaultWorkspacePath,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
@@ -759,6 +813,9 @@ function removeVaultFromState({
if (selectedVaultPath === removedPath) {
setSelectedVaultPath(null)
}
if (defaultWorkspacePath === removedPath) {
setDefaultWorkspacePath(null)
}
return
}
@@ -771,11 +828,15 @@ function removeVaultFromState({
})
if (remainingVaults.length === 0) {
setSelectedVaultPath(null)
setDefaultWorkspacePath(null)
return
}
const nextPath = remainingVaults[0].path
setSelectedVaultPath(nextPath)
if (defaultWorkspacePath === removedPath) {
setDefaultWorkspacePath(nextPath)
}
setVaultPath(nextPath)
onSwitchRef.current()
}
@@ -816,28 +877,18 @@ function useVaultClonedAction(
function useRegisterVaultSelectionAction({
defaultAvailable,
defaultPath,
defaultWorkspacePath,
extraVaults,
hiddenDefaults,
lastPersistedSnapshotRef,
onSwitchRef,
setDefaultAvailable,
setDefaultWorkspacePath,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
}: {
defaultAvailable: boolean
defaultPath: string
extraVaults: VaultOption[]
hiddenDefaults: string[]
lastPersistedSnapshotRef: MutableRefObject<string | null>
onSwitchRef: MutableRefObject<() => void>
setDefaultAvailable: Dispatch<SetStateAction<boolean>>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
setHiddenDefaults: Dispatch<SetStateAction<string[]>>
setSelectedVaultPath: Dispatch<SetStateAction<string | null>>
setVaultPath: Dispatch<SetStateAction<string>>
}) {
}: RegisterVaultSelectionActionDeps) {
return useCallback(async (path: string, label: string, options: RegisterVaultSelectionOptions = {}) => {
if (options.verifyAvailability !== false) {
await ensureVaultCanBeRegistered(path)
@@ -852,11 +903,13 @@ function useRegisterVaultSelectionAction({
path,
})
await persistRegisteredVaultSelection({
defaultWorkspacePath: defaultWorkspacePath ?? nextSelection.nextSelectedVaultPath,
hiddenDefaults: nextSelection.nextHiddenDefaults,
lastPersistedSnapshotRef,
selectedVaultPath: nextSelection.nextSelectedVaultPath,
vaults: nextSelection.nextExtraVaults,
})
setDefaultWorkspacePath((currentDefaultPath) => currentDefaultPath ?? nextSelection.nextSelectedVaultPath)
applyRegisteredVaultSelection({
...nextSelection,
onSwitchRef,
@@ -869,11 +922,13 @@ function useRegisterVaultSelectionAction({
}, [
defaultAvailable,
defaultPath,
defaultWorkspacePath,
extraVaults,
hiddenDefaults,
lastPersistedSnapshotRef,
onSwitchRef,
setDefaultAvailable,
setDefaultWorkspacePath,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
@@ -983,12 +1038,14 @@ function useCreateEmptyVaultAction(
}
function useRemoveVaultAction({
defaultWorkspacePath,
defaultVaults,
extraVaults,
hiddenDefaults,
onSwitchRef,
onToastRef,
setExtraVaults,
setDefaultWorkspacePath,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
@@ -999,6 +1056,7 @@ function useRemoveVaultAction({
const isDefault = defaultVaults.some(vault => vault.path === path)
removeVaultFromState({
defaultWorkspacePath,
defaultVaults,
extraVaults,
hiddenDefaults,
@@ -1006,6 +1064,7 @@ function useRemoveVaultAction({
onSwitchRef,
removedPath: path,
setExtraVaults,
setDefaultWorkspacePath,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
@@ -1014,12 +1073,14 @@ function useRemoveVaultAction({
})
onToastRef.current(`Vault "${getRemovedVaultLabel({ path, defaultVaults, extraVaults })}" removed from list`)
}, [
defaultWorkspacePath,
defaultVaults,
extraVaults,
hiddenDefaults,
onSwitchRef,
onToastRef,
setExtraVaults,
setDefaultWorkspacePath,
setHiddenDefaults,
setSelectedVaultPath,
setVaultPath,
@@ -1045,6 +1106,7 @@ function useRestoreGettingStartedAction(options: RestoreGettingStartedOptions) {
function useVaultActions({
defaultAvailable,
defaultPath,
defaultWorkspacePath,
defaultVaults,
extraVaults,
hiddenDefaults,
@@ -1052,6 +1114,7 @@ function useVaultActions({
onSwitchRef,
onToastRef,
setDefaultAvailable,
setDefaultWorkspacePath,
setExtraVaults,
setHiddenDefaults,
selectedVaultPath,
@@ -1064,14 +1127,17 @@ function useVaultActions({
}, [setExtraVaults])
const switchVault = useSwitchVaultAction(onSwitchRef, setSelectedVaultPath, setVaultPath)
const workspaceIdentityActions = useWorkspaceIdentityActions({ setDefaultWorkspacePath, setExtraVaults })
const registerVaultSelection = useRegisterVaultSelectionAction({
defaultAvailable,
defaultPath,
defaultWorkspacePath,
extraVaults,
hiddenDefaults,
lastPersistedSnapshotRef,
onSwitchRef,
setDefaultAvailable,
setDefaultWorkspacePath,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
@@ -1100,11 +1166,13 @@ function useVaultActions({
handleVaultCloned: useVaultClonedAction(addAndSwitch, onToastRef),
registerVaultSelection,
removeVault: useRemoveVaultAction({
defaultWorkspacePath,
defaultVaults,
extraVaults,
hiddenDefaults,
onSwitchRef,
onToastRef,
setDefaultWorkspacePath,
setExtraVaults,
setHiddenDefaults,
setSelectedVaultPath,
@@ -1119,8 +1187,10 @@ function useVaultActions({
setHiddenDefaults,
switchVault,
}),
setDefaultWorkspace: workspaceIdentityActions.setDefaultWorkspace,
syncVaultSelection,
switchVault,
updateWorkspaceIdentity: workspaceIdentityActions.updateWorkspaceIdentity,
}
}
@@ -1158,6 +1228,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
const {
defaultAvailable,
defaultPath,
defaultWorkspacePath,
extraVaults,
hiddenDefaults,
loaded,
@@ -1179,8 +1250,10 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
registerVaultSelection,
removeVault,
restoreGettingStarted,
setDefaultWorkspace,
syncVaultSelection,
switchVault,
updateWorkspaceIdentity,
} = useVaultActions({
...persistedState,
allVaults,
@@ -1193,6 +1266,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
return {
allVaults,
defaultPath,
defaultWorkspacePath,
handleCreateEmptyVault,
handleOpenLocalFolder,
handleVaultCloned,
@@ -1201,9 +1275,11 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
registerVaultSelection,
removeVault,
restoreGettingStarted,
setDefaultWorkspace,
selectedVaultPath,
syncVaultSelection,
switchVault,
updateWorkspaceIdentity,
vaultPath,
}
}

View File

@@ -0,0 +1,29 @@
import { useCallback, type Dispatch, type SetStateAction } from 'react'
import type { VaultOption } from '../components/status-bar/types'
function updateVaultOptionInList(
path: string,
patch: Partial<VaultOption>,
): (vaults: VaultOption[]) => VaultOption[] {
return (vaults) => vaults.map((vault) => (
vault.path === path ? { ...vault, ...patch, path: vault.path } : vault
))
}
export function useWorkspaceIdentityActions({
setDefaultWorkspacePath,
setExtraVaults,
}: {
setDefaultWorkspacePath: Dispatch<SetStateAction<string | null>>
setExtraVaults: Dispatch<SetStateAction<VaultOption[]>>
}) {
const updateWorkspaceIdentity = useCallback((path: string, patch: Partial<VaultOption>) => {
setExtraVaults(updateVaultOptionInList(path, patch))
}, [setExtraVaults])
const setDefaultWorkspace = useCallback((path: string) => {
setDefaultWorkspacePath(path)
}, [setDefaultWorkspacePath])
return { setDefaultWorkspace, updateWorkspaceIdentity }
}

View File

@@ -1,7 +1,9 @@
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import type { FolderNode, GitPushResult, VaultEntry, ViewFile } from '../types'
import type { VaultOption } from '../components/status-bar/types'
import { normalizeVaultEntries, normalizeViewFiles } from '../utils/vaultMetadataNormalization'
import { workspaceIdentityFromVault } from '../utils/workspaces'
interface TauriCallOptions {
command: string
@@ -13,6 +15,11 @@ interface VaultPathOptions {
vaultPath: string
}
interface MountedVaultEntriesOptions extends VaultPathOptions {
defaultWorkspacePath?: string | null
vaults?: VaultOption[]
}
interface CommitWithPushOptions extends VaultPathOptions {
message: string
}
@@ -52,12 +59,43 @@ function loadVaultEntriesWithCommand({ vaultPath, command }: VaultPathOptions &
.then((entries) => normalizeVaultEntries(entries, vaultPath))
}
function loadWorkspaceEntries(vault: VaultOption, defaultWorkspacePath?: string | null): Promise<VaultEntry[]> {
const workspace = workspaceIdentityFromVault(vault, { defaultWorkspacePath })
return tauriCall<unknown>({ command: isTauri() ? 'reload_vault' : 'list_vault', tauriArgs: { path: vault.path } })
.then((entries) => normalizeVaultEntries(entries, vault.path, workspace))
}
function uniqueMountedVaults({ vaultPath, vaults = [] }: MountedVaultEntriesOptions): VaultOption[] {
const byPath = new Map<string, VaultOption>()
for (const vault of vaults) {
if (vault.available === false || vault.mounted === false || !vault.path.trim()) continue
byPath.set(vault.path, vault)
}
if (vaultPath.trim() && !byPath.has(vaultPath)) {
byPath.set(vaultPath, { label: vaultPath.split('/').filter(Boolean).pop() || 'Workspace', path: vaultPath, mounted: true, available: true })
}
return [...byPath.values()]
}
function loadMountedVaultEntries(options: MountedVaultEntriesOptions): Promise<VaultEntry[]> {
const mountedVaults = uniqueMountedVaults(options)
if (mountedVaults.length <= 1) {
const onlyVault = mountedVaults[0]
return onlyVault
? loadWorkspaceEntries(onlyVault, options.defaultWorkspacePath)
: loadVaultEntries({ vaultPath: options.vaultPath })
}
return Promise.all(mountedVaults.map((vault) => loadWorkspaceEntries(vault, options.defaultWorkspacePath)))
.then((groups) => groups.flat())
}
function loadVaultEntries({ vaultPath }: VaultPathOptions): Promise<VaultEntry[]> {
const command = isTauri() ? 'reload_vault' : 'list_vault'
return loadVaultEntriesWithCommand({ vaultPath, command })
}
export function reloadVaultEntries({ vaultPath }: VaultPathOptions): Promise<VaultEntry[]> {
export function reloadVaultEntries({ vaultPath, vaults, defaultWorkspacePath }: MountedVaultEntriesOptions): Promise<VaultEntry[]> {
if (vaults?.length) return loadMountedVaultEntries({ vaultPath, vaults, defaultWorkspacePath })
return loadVaultEntriesWithCommand({ vaultPath, command: 'reload_vault' })
}
@@ -70,9 +108,11 @@ export function loadVaultViews({ vaultPath }: VaultPathOptions): Promise<ViewFil
.then(normalizeViewFiles)
}
export async function loadVaultData({ vaultPath }: VaultPathOptions): Promise<LoadedVaultData> {
export async function loadVaultData({ vaultPath, vaults, defaultWorkspacePath }: MountedVaultEntriesOptions): Promise<LoadedVaultData> {
if (!isTauri()) console.info('[mock] Using mock Tauri data for browser testing')
const entries = await loadVaultEntries({ vaultPath })
const entries = vaults?.length
? await loadMountedVaultEntries({ vaultPath, vaults, defaultWorkspacePath })
: await loadVaultEntries({ vaultPath })
console.log(`Vault scan complete: ${entries.length} entries found`)
return { entries }
}

View File

@@ -0,0 +1,29 @@
import type { VaultOption } from '../components/StatusBar'
export function sanitizeDefaultWorkspacePath({
activeVault,
defaultAvailable,
defaultWorkspacePath,
resolvedDefaultPath,
vaults,
}: {
activeVault: string | null
defaultAvailable: boolean
defaultWorkspacePath: string | null
resolvedDefaultPath: string
vaults: VaultOption[]
}): string | null {
if (!defaultWorkspacePath) {
return null
}
if (defaultWorkspacePath === resolvedDefaultPath) {
return defaultAvailable ? defaultWorkspacePath : null
}
if (vaults.some((vault) => vault.path === defaultWorkspacePath)) {
return defaultWorkspacePath
}
return defaultWorkspacePath === activeVault ? activeVault : null
}

View File

@@ -515,10 +515,18 @@
"status.vault.openLocal": "Lokalen Ordner öffnen",
"status.vault.cloneGit": "Git-Repo klonen",
"status.vault.cloneGettingStarted": "Getting-Started-Vault klonen",
"status.vault.manageWorkspaces": "Workspaces verwalten …",
"status.vault.notFound": "Vault nicht gefunden: {path}",
"status.vault.reloading": "Vault wird neu geladen …",
"status.vault.reloadingTooltip": "Vault von der Festplatte neu laden",
"status.vault.remove": "{label} aus der Liste entfernen",
"workspace.manager.title": "Workspaces verwalten",
"workspace.manager.description": "Workspace-Identität bearbeiten und Standardziel für neue Notizen auswählen.",
"workspace.manager.default": "Standard",
"workspace.manager.makeDefault": "Als Standard festlegen",
"workspace.manager.label": "Workspace-Label",
"workspace.manager.alias": "Stabiler Alias",
"workspace.manager.mounted": "Im Graphen eingebunden",
"status.remote.noneConfigured": "Kein Remote konfiguriert",
"status.remote.inSync": "Mit Remote synchronisiert",
"status.remote.aheadTitle": "{count} Commit{plural} vor dem Remote-Branch",

View File

@@ -515,10 +515,18 @@
"status.vault.openLocal": "Open local folder",
"status.vault.cloneGit": "Clone Git repo",
"status.vault.cloneGettingStarted": "Clone Getting Started Vault",
"status.vault.manageWorkspaces": "Manage Workspaces...",
"status.vault.notFound": "Vault not found: {path}",
"status.vault.reloading": "Reloading vault...",
"status.vault.reloadingTooltip": "Reloading vault from disk",
"status.vault.remove": "Remove {label} from list",
"workspace.manager.title": "Manage Workspaces",
"workspace.manager.description": "Edit workspace identity and choose the default destination for new notes.",
"workspace.manager.default": "Default",
"workspace.manager.makeDefault": "Make Default",
"workspace.manager.label": "Workspace label",
"workspace.manager.alias": "Stable alias",
"workspace.manager.mounted": "Mounted in graph",
"status.remote.noneConfigured": "No remote configured",
"status.remote.inSync": "In sync with remote",
"status.remote.aheadTitle": "{count} commit{plural} ahead of remote",

View File

@@ -515,10 +515,18 @@
"status.vault.openLocal": "Abrir carpeta local",
"status.vault.cloneGit": "Clonar repositorio Git",
"status.vault.cloneGettingStarted": "Clonar el vault de introducción",
"status.vault.manageWorkspaces": "Administrar espacios de trabajo...",
"status.vault.notFound": "No se encontró el almacén: {path}",
"status.vault.reloading": "Volviendo a cargar el vault...",
"status.vault.reloadingTooltip": "Volviendo a cargar el repositorio desde el disco",
"status.vault.remove": "Eliminar {label} de la lista",
"workspace.manager.title": "Administrar espacios de trabajo",
"workspace.manager.description": "Edite la identidad del espacio de trabajo y elija el destino predeterminado para las notas nuevas.",
"workspace.manager.default": "Predeterminado",
"workspace.manager.makeDefault": "Establecer como predeterminado",
"workspace.manager.label": "Etiqueta del espacio de trabajo",
"workspace.manager.alias": "Alias estable",
"workspace.manager.mounted": "Montado en el grafo",
"status.remote.noneConfigured": "No se ha configurado ningún repositorio remoto",
"status.remote.inSync": "Sincronizado con el repositorio remoto",
"status.remote.aheadTitle": "{count} commit{plural} por delante del repositorio remoto",

View File

@@ -515,10 +515,18 @@
"status.vault.openLocal": "Abrir carpeta local",
"status.vault.cloneGit": "Clonar repositorio Git",
"status.vault.cloneGettingStarted": "Clonar el almacén «Primeros pasos»",
"status.vault.manageWorkspaces": "Gestionar espacios de trabajo...",
"status.vault.notFound": "No se ha encontrado el almacén: {path}",
"status.vault.reloading": "Volviendo a cargar el repositorio...",
"status.vault.reloadingTooltip": "Recargando el repositorio desde el disco",
"status.vault.remove": "Eliminar {label} de la lista",
"workspace.manager.title": "Gestionar espacios de trabajo",
"workspace.manager.description": "Editar la identidad del espacio de trabajo y elegir el destino predeterminado para las notas nuevas.",
"workspace.manager.default": "Predeterminado",
"workspace.manager.makeDefault": "Establecer como predeterminado",
"workspace.manager.label": "Etiqueta del espacio de trabajo",
"workspace.manager.alias": "Alias estable",
"workspace.manager.mounted": "Montado en el grafo",
"status.remote.noneConfigured": "No hay ningún repositorio remoto configurado",
"status.remote.inSync": "Sincronizado con el repositorio remoto",
"status.remote.aheadTitle": "{count} commit{plural} por delante del repositorio remoto",

View File

@@ -515,10 +515,18 @@
"status.vault.openLocal": "Ouvrir le dossier local",
"status.vault.cloneGit": "Cloner le dépôt Git",
"status.vault.cloneGettingStarted": "Cloner le coffre-fort « Premiers pas »",
"status.vault.manageWorkspaces": "Gérer les espaces de travail…",
"status.vault.notFound": "Coffre-fort introuvable : {path}",
"status.vault.reloading": "Rechargement du coffre-fort…",
"status.vault.reloadingTooltip": "Rechargement du coffre à partir du disque",
"status.vault.remove": "Supprimer {label} de la liste",
"workspace.manager.title": "Gérer les espaces de travail",
"workspace.manager.description": "Modifier l'identité de l'espace de travail et choisir la destination par défaut pour les nouvelles notes.",
"workspace.manager.default": "Par défaut",
"workspace.manager.makeDefault": "Définir par défaut",
"workspace.manager.label": "Étiquette de l'espace de travail",
"workspace.manager.alias": "Alias stable",
"workspace.manager.mounted": "Monté dans le graphe",
"status.remote.noneConfigured": "Aucun serveur distant configuré",
"status.remote.inSync": "Synchronisé avec le dépôt distant",
"status.remote.aheadTitle": "{count} commit{plural} en avance sur le dépôt distant",

View File

@@ -515,10 +515,18 @@
"status.vault.openLocal": "Apri cartella locale",
"status.vault.cloneGit": "Clona repository Git",
"status.vault.cloneGettingStarted": "Clona il vault Getting Started",
"status.vault.manageWorkspaces": "Gestisci gli spazi di lavoro...",
"status.vault.notFound": "Vault non trovato: {path}",
"status.vault.reloading": "Ricaricamento del vault in corso...",
"status.vault.reloadingTooltip": "Ricaricamento del vault dal disco in corso...",
"status.vault.remove": "Rimuovi {label} dall'elenco",
"workspace.manager.title": "Gestisci gli spazi di lavoro",
"workspace.manager.description": "Modifica l'identità dell'area di lavoro e scegli la destinazione predefinita per le nuove note.",
"workspace.manager.default": "Predefinito",
"workspace.manager.makeDefault": "Imposta come predefinito",
"workspace.manager.label": "Etichetta dell'area di lavoro",
"workspace.manager.alias": "Alias stabile",
"workspace.manager.mounted": "Montato nel grafo",
"status.remote.noneConfigured": "Nessun remoto configurato",
"status.remote.inSync": "Sincronizzato con il repository remoto",
"status.remote.aheadTitle": "{count} commit{plural} in anticipo rispetto al repository remoto",

View File

@@ -515,10 +515,18 @@
"status.vault.openLocal": "ローカルフォルダーを開く",
"status.vault.cloneGit": "Gitリポジトリをクローンする",
"status.vault.cloneGettingStarted": "Getting Started Vault をクローンする",
"status.vault.manageWorkspaces": "ワークスペースを管理...",
"status.vault.notFound": "ボールトが見つかりません:{path}",
"status.vault.reloading": "Vault を再読み込み中...",
"status.vault.reloadingTooltip": "ディスクから Vault を再読み込み中",
"status.vault.remove": "{label}をリストから削除",
"workspace.manager.title": "ワークスペースの管理",
"workspace.manager.description": "ワークスペースのIDを編集し、新しいートの既定の保存先を選択します。",
"workspace.manager.default": "デフォルト",
"workspace.manager.makeDefault": "デフォルトに設定",
"workspace.manager.label": "Workspace label",
"workspace.manager.alias": "Stable alias",
"workspace.manager.mounted": "グラフにマウント済み",
"status.remote.noneConfigured": "リモートが設定されていません",
"status.remote.inSync": "リモートと同期済み",
"status.remote.aheadTitle": "リモートより{count}件先行するコミット{plural}",

View File

@@ -515,10 +515,18 @@
"status.vault.openLocal": "로컬 폴더 열기",
"status.vault.cloneGit": "Git 리포지토리 복제",
"status.vault.cloneGettingStarted": "Getting Started Vault 복제",
"status.vault.manageWorkspaces": "작업 공간 관리...",
"status.vault.notFound": "Vault를 찾을 수 없음: {path}",
"status.vault.reloading": "Vault 다시 로드 중...",
"status.vault.reloadingTooltip": "디스크에서 Vault 다시 로드 중",
"status.vault.remove": "목록에서 {label} 제거",
"workspace.manager.title": "작업 공간 관리",
"workspace.manager.description": "워크스페이스 ID를 편집하고 새 노트의 기본 저장 위치를 선택하세요.",
"workspace.manager.default": "기본값",
"workspace.manager.makeDefault": "기본으로 설정",
"workspace.manager.label": "Workspace label",
"workspace.manager.alias": "Stable alias",
"workspace.manager.mounted": "그래프에 마운트됨",
"status.remote.noneConfigured": "구성된 원격 없음",
"status.remote.inSync": "원격과 동기화됨",
"status.remote.aheadTitle": "원격 브랜치보다 {count}건의 커밋{plural} 앞서 있음",

View File

@@ -515,10 +515,18 @@
"status.vault.openLocal": "Otwórz lokalny folder",
"status.vault.cloneGit": "Klonuj repozytorium Git",
"status.vault.cloneGettingStarted": "Klonuj sejf Wprowadzenie",
"status.vault.manageWorkspaces": "Zarządzaj obszarami roboczymi...",
"status.vault.notFound": "Sejf nie znaleziony: {path}",
"status.vault.reloading": "Przeładowywanie sejfu...",
"status.vault.reloadingTooltip": "Przeładowywanie sejfu z dysku",
"status.vault.remove": "Usuń {label} z listy",
"workspace.manager.title": "Zarządzaj obszarami roboczymi",
"workspace.manager.description": "Edytuj tożsamość obszaru roboczego i wybierz domyślne miejsce docelowe dla nowych notatek.",
"workspace.manager.default": "Domyślne",
"workspace.manager.makeDefault": "Ustaw jako domyślny",
"workspace.manager.label": "Etykieta obszaru roboczego",
"workspace.manager.alias": "Stabilny alias",
"workspace.manager.mounted": "Zamontowane w grafu",
"status.remote.noneConfigured": "Brak skonfigurowanego repozytorium zdalnego",
"status.remote.inSync": "Zsynchronizowano z repozytorium zdalnym",
"status.remote.aheadTitle": "{count} zatwierdzeń{plural} przed repozytorium zdalnym",

View File

@@ -515,10 +515,18 @@
"status.vault.openLocal": "Abrir pasta local",
"status.vault.cloneGit": "Clonar repositório Git",
"status.vault.cloneGettingStarted": "Clonar cofre de introdução",
"status.vault.manageWorkspaces": "Gerenciar espaços de trabalho...",
"status.vault.notFound": "Cofre não encontrado: {path}",
"status.vault.reloading": "Recarregando o cofre...",
"status.vault.reloadingTooltip": "Recarregando o vault a partir do disco",
"status.vault.remove": "Remover {label} da lista",
"workspace.manager.title": "Gerenciar espaços de trabalho",
"workspace.manager.description": "Edite a identidade do espaço de trabalho e escolha o destino padrão para novas anotações.",
"workspace.manager.default": "Padrão",
"workspace.manager.makeDefault": "Definir como padrão",
"workspace.manager.label": "Rótulo do espaço de trabalho",
"workspace.manager.alias": "Alias estável",
"workspace.manager.mounted": "Montado no grafo",
"status.remote.noneConfigured": "Nenhum repositório remoto configurado",
"status.remote.inSync": "Sincronizado com o repositório remoto",
"status.remote.aheadTitle": "{count} commit{plural} à frente do repositório remoto",

View File

@@ -515,10 +515,18 @@
"status.vault.openLocal": "Abrir pasta local",
"status.vault.cloneGit": "Clonar repositório Git",
"status.vault.cloneGettingStarted": "Clonar cofre Getting Started",
"status.vault.manageWorkspaces": "Gerir espaços de trabalho...",
"status.vault.notFound": "Cofre não encontrado: {path}",
"status.vault.reloading": "A recarregar o cofre...",
"status.vault.reloadingTooltip": "A recarregar o cofre a partir do disco",
"status.vault.remove": "Remover {label} da lista",
"workspace.manager.title": "Gerir espaços de trabalho",
"workspace.manager.description": "Editar a identidade do espaço de trabalho e escolher o destino predefinido para novas notas.",
"workspace.manager.default": "Predefinido",
"workspace.manager.makeDefault": "Definir como predefinido",
"workspace.manager.label": "Etiqueta do espaço de trabalho",
"workspace.manager.alias": "Alias estável",
"workspace.manager.mounted": "Montado no grafo",
"status.remote.noneConfigured": "Nenhum repositório remoto configurado",
"status.remote.inSync": "Sincronizado com o repositório remoto",
"status.remote.aheadTitle": "{count} commit{plural} à frente do repositório remoto",

View File

@@ -515,10 +515,18 @@
"status.vault.openLocal": "Открыть локальную папку",
"status.vault.cloneGit": "Клонировать Git-репозиторий",
"status.vault.cloneGettingStarted": "Клонировать хранилище «Начало работы»",
"status.vault.manageWorkspaces": "Управление рабочими пространствами...",
"status.vault.notFound": "Хранилище не найдено: {path}",
"status.vault.reloading": "Перезагрузка хранилища...",
"status.vault.reloadingTooltip": "Перезагрузка хранилища с диска",
"status.vault.remove": "Удалить {label} из списка",
"workspace.manager.title": "Управление рабочими пространствами",
"workspace.manager.description": "Отредактируйте идентификатор рабочего пространства и выберите место по умолчанию для сохранения новых заметок.",
"workspace.manager.default": "По умолчанию",
"workspace.manager.makeDefault": "Сделать по умолчанию",
"workspace.manager.label": "Метка рабочего пространства",
"workspace.manager.alias": "Стабильный псевдоним",
"workspace.manager.mounted": "Добавлено в граф",
"status.remote.noneConfigured": "Ни один удаленный репозиторий не настроен",
"status.remote.inSync": "Синхронизировано с удаленным репозиторием",
"status.remote.aheadTitle": "На {count} коммит{plural} опережает удаленный репозиторий",

View File

@@ -515,10 +515,18 @@
"status.vault.openLocal": "Mở thư mục cục bộ",
"status.vault.cloneGit": "Nhân bản kho Git",
"status.vault.cloneGettingStarted": "Nhân bản kho Getting Started",
"status.vault.manageWorkspaces": "Quản lý không gian làm việc...",
"status.vault.notFound": "Không tìm thấy kho: {path}",
"status.vault.reloading": "Đang tải lại kho...",
"status.vault.reloadingTooltip": "Đang tải lại kho từ đĩa",
"status.vault.remove": "Xóa {label} khỏi danh sách",
"workspace.manager.title": "Quản lý không gian làm việc",
"workspace.manager.description": "Chỉnh sửa thông tin nhận dạng của không gian làm việc và chọn vị trí mặc định cho các ghi chú mới.",
"workspace.manager.default": "Mặc định",
"workspace.manager.makeDefault": "Đặt làm mặc định",
"workspace.manager.label": "Nhãn không gian làm việc",
"workspace.manager.alias": "Bí danh ổn định",
"workspace.manager.mounted": "Được gắn trong đồ thị",
"status.remote.noneConfigured": "Chưa cấu hình remote",
"status.remote.inSync": "Đồng bộ với remote",
"status.remote.aheadTitle": "{count} commit{plural} đi trước remote",

View File

@@ -515,10 +515,18 @@
"status.vault.openLocal": "打开本地文件夹",
"status.vault.cloneGit": "克隆 Git 仓库",
"status.vault.cloneGettingStarted": "克隆入门仓库",
"status.vault.manageWorkspaces": "管理工作空间...",
"status.vault.notFound": "未找到仓库:{path}",
"status.vault.reloading": "正在重新加载 Vault...",
"status.vault.reloadingTooltip": "正在从磁盘重新加载 Vault",
"status.vault.remove": "从列表移除 {label}",
"workspace.manager.title": "管理工作空间",
"workspace.manager.description": "编辑工作空间标识并选择新笔记的默认目标位置。",
"workspace.manager.default": "默认",
"workspace.manager.makeDefault": "设为默认",
"workspace.manager.label": "工作空间标签",
"workspace.manager.alias": "稳定别名",
"workspace.manager.mounted": "已挂载到图中",
"status.remote.noneConfigured": "未配置远程仓库",
"status.remote.inSync": "已与远程同步",
"status.remote.aheadTitle": "领先远程 {count} 个提交{plural}",

View File

@@ -515,10 +515,18 @@
"status.vault.openLocal": "開啟本地資料夾",
"status.vault.cloneGit": "克隆 Git 倉庫",
"status.vault.cloneGettingStarted": "克隆入門倉庫",
"status.vault.manageWorkspaces": "管理工作空間…",
"status.vault.notFound": "未找到倉庫:{path}",
"status.vault.reloading": "正在重新載入 Vault……",
"status.vault.reloadingTooltip": "正在從磁碟重新載入 Vault",
"status.vault.remove": "從列表移除 {label}",
"workspace.manager.title": "管理工作空間",
"workspace.manager.description": "編輯工作空間身分,並選擇新筆記的預設儲存位置。",
"workspace.manager.default": "預設",
"workspace.manager.makeDefault": "設為預設",
"workspace.manager.label": "工作空間標籤",
"workspace.manager.alias": "穩定別名",
"workspace.manager.mounted": "已掛載至圖形中",
"status.remote.noneConfigured": "未配置遠端倉庫",
"status.remote.inSync": "已與遠端同步",
"status.remote.aheadTitle": "領先遠端 {count} 個提交{plural}",

View File

@@ -8,6 +8,7 @@ export interface VaultEntry {
path: string
filename: string
title: string
workspace?: WorkspaceIdentity
isA: string | null
aliases: string[]
belongsTo: string[]
@@ -60,6 +61,19 @@ export interface VaultEntry {
fileKind?: 'markdown' | 'text' | 'binary'
}
export interface WorkspaceIdentity {
id: string
label: string
alias: string
path: string
shortLabel: string
color: string | null
icon: string | null
mounted: boolean
available: boolean
defaultForNewNotes: boolean
}
export type NoteStatus = 'new' | 'modified' | 'clean' | 'pendingSave' | 'unsaved'
export interface GitCommit {

View File

@@ -3,36 +3,78 @@ import { isTauri, mockInvoke } from '../mock-tauri'
import type { VaultOption } from '../components/StatusBar'
export interface PersistedVaultList {
vaults: Array<{ label: string; path: string }>
vaults: Array<{
label: string
path: string
alias?: string | null
color?: string | null
icon?: string | null
mounted?: boolean | null
}>
active_vault: string | null
default_workspace_path?: string | null
hidden_defaults: string[]
}
function persistedVaultOption(v: PersistedVaultList['vaults'][number]): VaultOption {
return {
label: v.label,
path: v.path,
alias: v.alias ?? undefined,
color: v.color ?? null,
icon: v.icon ?? null,
mounted: v.mounted !== false,
}
}
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
async function checkAvailability(v: { label: string; path: string }): Promise<VaultOption> {
async function checkAvailability(v: PersistedVaultList['vaults'][number]): Promise<VaultOption> {
try {
const exists = await tauriCall<boolean>('check_vault_exists', { path: v.path })
return { label: v.label, path: v.path, available: exists }
return { ...persistedVaultOption(v), available: exists }
} catch (error) {
void error
return { label: v.label, path: v.path, available: false }
return { ...persistedVaultOption(v), available: false }
}
}
export async function loadVaultList(): Promise<{ vaults: VaultOption[]; activeVault: string | null; hiddenDefaults: string[] }> {
export async function loadVaultList(): Promise<{
vaults: VaultOption[]
activeVault: string | null
defaultWorkspacePath: string | null
hiddenDefaults: string[]
}> {
const data = await tauriCall<PersistedVaultList>('load_vault_list', {})
const persisted = data?.vaults ?? []
const checked = await Promise.all(persisted.map(checkAvailability))
return { vaults: checked, activeVault: data?.active_vault ?? null, hiddenDefaults: data?.hidden_defaults ?? [] }
return {
vaults: checked,
activeVault: data?.active_vault ?? null,
defaultWorkspacePath: data?.default_workspace_path ?? data?.active_vault ?? null,
hiddenDefaults: data?.hidden_defaults ?? [],
}
}
export function saveVaultList(vaults: VaultOption[], activeVault: string | null, hiddenDefaults: string[] = []): Promise<void> {
export function saveVaultList(
vaults: VaultOption[],
activeVault: string | null,
hiddenDefaults: string[] = [],
defaultWorkspacePath: string | null = activeVault,
): Promise<void> {
const list: PersistedVaultList = {
vaults: vaults.map(v => ({ label: v.label, path: v.path })),
vaults: vaults.map(v => ({
label: v.label,
path: v.path,
alias: v.alias ?? null,
color: v.color ?? null,
icon: v.icon ?? null,
mounted: v.mounted !== false,
})),
active_vault: activeVault,
default_workspace_path: defaultWorkspacePath,
hidden_defaults: hiddenDefaults,
}
return tauriCall('save_vault_list', { list })

View File

@@ -1,4 +1,4 @@
import type { FilterGroup, FilterNode, VaultEntry, ViewDefinition, ViewFile } from '../types'
import type { FilterGroup, FilterNode, VaultEntry, ViewDefinition, ViewFile, WorkspaceIdentity } from '../types'
type UnknownRecord = Record<string, unknown>
@@ -6,6 +6,7 @@ interface EntryNormalizationArgs {
rawEntry: unknown
vaultPath: string
index: number
workspace?: WorkspaceIdentity
}
interface EntryPathArgs {
@@ -129,7 +130,7 @@ function fallbackViewName(filename: string, index: number): string {
return stem && stem !== `view-${index + 1}` ? stem : `View ${index + 1}`
}
function normalizeVaultEntryRecord({ rawEntry, vaultPath, index }: EntryNormalizationArgs): VaultEntry {
function normalizeVaultEntryRecord({ rawEntry, vaultPath, index, workspace }: EntryNormalizationArgs): VaultEntry {
const source = recordFrom(rawEntry)
const filename = fallbackEntryFilename(source, index)
const path = resolveEntryPath({
@@ -145,6 +146,7 @@ function normalizeVaultEntryRecord({ rawEntry, vaultPath, index }: EntryNormaliz
path,
filename,
title,
workspace: workspace ?? (isRecord(source.workspace) ? source.workspace as WorkspaceIdentity : undefined),
isA: nullableStringFrom(source.isA),
aliases: stringArrayFrom(source.aliases),
belongsTo: stringArrayFrom(source.belongsTo),
@@ -212,15 +214,15 @@ function normalizeViewFile({ rawView, index }: ViewNormalizationArgs): ViewFile
}
}
export function normalizeVaultEntries(rawEntries: unknown, vaultPath: string): VaultEntry[] {
export function normalizeVaultEntries(rawEntries: unknown, vaultPath: string, workspace?: WorkspaceIdentity): VaultEntry[] {
if (!Array.isArray(rawEntries)) return []
return rawEntries
.filter(hasUsablePath)
.map((rawEntry, index) => normalizeVaultEntry(rawEntry, vaultPath, index))
.map((rawEntry, index) => normalizeVaultEntry(rawEntry, vaultPath, index, workspace))
}
export function normalizeVaultEntry(rawEntry: unknown, vaultPath = '', index = 0): VaultEntry {
return normalizeVaultEntryRecord({ rawEntry, vaultPath, index })
export function normalizeVaultEntry(rawEntry: unknown, vaultPath = '', index = 0, workspace?: WorkspaceIdentity): VaultEntry {
return normalizeVaultEntryRecord({ rawEntry, vaultPath, index, workspace })
}
export function normalizeViewFiles(rawViews: unknown): ViewFile[] {

View File

@@ -11,7 +11,10 @@ function compareVaultContainmentPath(path: string, vaultPath: string): [string,
: [normalizedPath, normalizedVaultPath]
}
export function canWritePathToVault(path: string, vaultPath: string): boolean {
export function canWritePathToVault(path: string, vaultPath: string | readonly string[]): boolean {
if (Array.isArray(vaultPath)) {
return vaultPath.some((candidate) => canWritePathToVault(path, candidate))
}
const trimmedVaultPath = vaultPath.trim()
if (!trimmedVaultPath) return true
const [targetPath, rootPath] = compareVaultContainmentPath(path, trimmedVaultPath)

View File

@@ -118,6 +118,40 @@ describe('resolveEntry', () => {
expect(resolveEntry([alpha, alphaArchived], 'projects/alpha')).toBe(alpha)
expect(resolveEntry([alpha, alphaArchived], 'archive/alpha')).toBe(alphaArchived)
})
it('resolves workspace-prefixed targets inside the matching mounted workspace', () => {
const personal = makeEntry({
path: '/personal/projects/alpha.md',
filename: 'alpha.md',
title: 'Alpha',
workspace: { id: 'personal', label: 'Personal', alias: 'personal', path: '/personal', shortLabel: 'PE', color: null, icon: null, mounted: true, available: true, defaultForNewNotes: true },
})
const team = makeEntry({
path: '/team/projects/alpha.md',
filename: 'alpha.md',
title: 'Alpha',
workspace: { id: 'team', label: 'Team', alias: 'team', path: '/team', shortLabel: 'TE', color: null, icon: null, mounted: true, available: true, defaultForNewNotes: false },
})
expect(resolveEntry([personal, team], 'team/projects/alpha', personal)).toBe(team)
})
it('prefers local workspace targets when links are unprefixed', () => {
const personal = makeEntry({
path: '/personal/alpha.md',
filename: 'alpha.md',
title: 'Alpha',
workspace: { id: 'personal', label: 'Personal', alias: 'personal', path: '/personal', shortLabel: 'PE', color: null, icon: null, mounted: true, available: true, defaultForNewNotes: true },
})
const team = makeEntry({
path: '/team/alpha.md',
filename: 'alpha.md',
title: 'Alpha',
workspace: { id: 'team', label: 'Team', alias: 'team', path: '/team', shortLabel: 'TE', color: null, icon: null, mounted: true, available: true, defaultForNewNotes: false },
})
expect(resolveEntry([team, personal], 'alpha', personal)).toBe(personal)
})
})
describe('relativePathStem', () => {
@@ -171,6 +205,23 @@ describe('canonicalWikilinkTargetForEntry', () => {
const entry = makeEntry({ path: '/vault/projects/alpha.md', filename: 'alpha.md', title: 'Alpha' })
expect(canonicalWikilinkTargetForEntry(entry, '/vault')).toBe('projects/alpha')
})
it('prefixes cross-workspace targets with the stable alias', () => {
const source = makeEntry({
path: '/personal/source.md',
filename: 'source.md',
title: 'Source',
workspace: { id: 'personal', label: 'Personal', alias: 'personal', path: '/personal', shortLabel: 'PE', color: null, icon: null, mounted: true, available: true, defaultForNewNotes: true },
})
const target = makeEntry({
path: '/team/projects/alpha.md',
filename: 'alpha.md',
title: 'Alpha',
workspace: { id: 'team', label: 'Team', alias: 'team', path: '/team', shortLabel: 'TE', color: null, icon: null, mounted: true, available: true, defaultForNewNotes: false },
})
expect(canonicalWikilinkTargetForEntry(target, '/personal', source)).toBe('team/projects/alpha')
})
})
describe('canonicalWikilinkTargetForTitle', () => {

View File

@@ -2,6 +2,7 @@
import type { VaultEntry } from '../types'
import { slugifyNoteStem } from './noteSlug'
import { workspaceForEntry, workspacePathForEntry } from './workspaces'
export type AbsoluteNotePath = string
export type NoteTitleOrTarget = string
@@ -57,9 +58,21 @@ export function relativePathStem(absolutePath: AbsoluteNotePath, vaultPath: Vaul
/** Slugify a human-readable title into the canonical wikilink filename stem. */
export const slugifyWikilinkTarget = slugifyNoteStem
function shouldPrefixWorkspaceAlias(entryAlias?: string, sourceAlias?: string): boolean {
return !!entryAlias && !!sourceAlias && entryAlias !== sourceAlias
}
/** Build the canonical wikilink target for a vault entry. */
export function canonicalWikilinkTargetForEntry(entry: VaultEntry, vaultPath: VaultPath): WikilinkTarget {
return relativePathStem(entry.path, vaultPath)
export function canonicalWikilinkTargetForEntry(entry: VaultEntry, vaultPath: VaultPath, sourceEntry?: VaultEntry): WikilinkTarget {
const entryWorkspace = workspaceForEntry(entry)
const sourceWorkspace = sourceEntry ? workspaceForEntry(sourceEntry) : null
const entryVaultPath = workspacePathForEntry(entry) ?? vaultPath
const localTarget = relativePathStem(entry.path, entryVaultPath)
const entryAlias = entryWorkspace?.alias
if (shouldPrefixWorkspaceAlias(entryAlias, sourceWorkspace?.alias)) {
return `${entryAlias}/${localTarget}`
}
return localTarget
}
/** Resolve a user-facing title/path input to the canonical wikilink target. */
@@ -67,11 +80,12 @@ export function canonicalWikilinkTargetForTitle(
titleOrTarget: NoteTitleOrTarget,
entries: VaultEntry[],
vaultPath: VaultPath,
sourceEntry?: VaultEntry,
): WikilinkTarget {
const trimmed = titleOrTarget.trim()
const resolved = resolveEntry(entries, trimmed)
const resolved = resolveEntry(entries, trimmed, sourceEntry)
return resolved
? canonicalWikilinkTargetForEntry(resolved, vaultPath)
? canonicalWikilinkTargetForEntry(resolved, vaultPath, sourceEntry)
: trimmed.includes('/')
? trimmed.replace(/^\/+/, '').replace(/\.md$/, '')
: slugifyWikilinkTarget(trimmed)
@@ -84,46 +98,74 @@ export function formatWikilinkRef(target: WikilinkTarget): WikilinkReference {
interface ResolutionKey {
exactTarget: string
workspaceAlias: string | null
targetWithoutWorkspace: string
lastSegment: string
pathSuffix: string | null
humanizedTarget: string | null
}
function buildResolutionKey(rawTarget: WikilinkTarget): ResolutionKey {
function buildResolutionKey(rawTarget: WikilinkTarget, knownWorkspaceAliases: Set<string> = new Set()): ResolutionKey {
const exactTarget = rawTarget.includes('|') ? rawTarget.split('|')[0] : rawTarget
const normalizedTarget = exactTarget.toLowerCase()
const lastSegment = exactTarget.includes('/') ? (exactTarget.split('/').pop() ?? exactTarget).toLowerCase() : normalizedTarget
const segments = exactTarget.split('/').filter(Boolean)
const candidateWorkspaceAlias = segments.length > 1 ? segments[0].toLowerCase() : null
const workspaceAlias = candidateWorkspaceAlias && knownWorkspaceAliases.has(candidateWorkspaceAlias)
? candidateWorkspaceAlias
: null
const targetWithoutWorkspace = workspaceAlias ? segments.slice(1).join('/') : exactTarget
const normalizedLocalTarget = targetWithoutWorkspace.toLowerCase()
const lastSegment = targetWithoutWorkspace.includes('/') ? (targetWithoutWorkspace.split('/').pop() ?? targetWithoutWorkspace).toLowerCase() : normalizedLocalTarget
const humanizedTarget = lastSegment.replace(/-/g, ' ')
return {
exactTarget: normalizedTarget,
workspaceAlias,
targetWithoutWorkspace: normalizedLocalTarget,
lastSegment,
pathSuffix: exactTarget.includes('/') ? `/${normalizedTarget}.md` : null,
humanizedTarget: humanizedTarget === normalizedTarget ? null : humanizedTarget,
pathSuffix: targetWithoutWorkspace.includes('/') ? `/${normalizedLocalTarget}.md` : null,
humanizedTarget: humanizedTarget === normalizedLocalTarget ? null : humanizedTarget,
}
}
function filterEntriesByWorkspace(entries: VaultEntry[], alias: string | null): VaultEntry[] {
if (!alias) return entries
return entries.filter((entry) => workspaceForEntry(entry)?.alias.toLowerCase() === alias)
}
function prioritizeSourceWorkspace(entries: VaultEntry[], sourceEntry?: VaultEntry): VaultEntry[] {
const sourceWorkspace = sourceEntry ? workspaceForEntry(sourceEntry) : null
if (!sourceWorkspace) return entries
return [
...entries.filter((entry) => workspaceForEntry(entry)?.alias === sourceWorkspace.alias),
...entries.filter((entry) => workspaceForEntry(entry)?.alias !== sourceWorkspace.alias),
]
}
function findEntryByPathSuffix(entries: VaultEntry[], resolutionKey: ResolutionKey): VaultEntry | undefined {
if (!resolutionKey.pathSuffix) return undefined
const { pathSuffix } = resolutionKey
return entries.find(entry => entry.path.toLowerCase().endsWith(pathSuffix))
}
function findEntryByFilename(entries: VaultEntry[], { exactTarget, lastSegment }: ResolutionKey): VaultEntry | undefined {
function findEntryByFilename(entries: VaultEntry[], { exactTarget, targetWithoutWorkspace, lastSegment }: ResolutionKey): VaultEntry | undefined {
return entries.find((entry) => {
const stem = entry.filename.replace(/\.md$/, '').toLowerCase()
return stem === exactTarget || stem === lastSegment
return stem === exactTarget || stem === targetWithoutWorkspace || stem === lastSegment
})
}
function findEntryByAlias(entries: VaultEntry[], resolutionKey: ResolutionKey): VaultEntry | undefined {
return entries.find(entry => entry.aliases.some(alias => alias.toLowerCase() === resolutionKey.exactTarget))
return entries.find(entry => entry.aliases.some((alias) => {
const normalizedAlias = alias.toLowerCase()
return normalizedAlias === resolutionKey.exactTarget || normalizedAlias === resolutionKey.targetWithoutWorkspace
}))
}
function findEntryByTitle(entries: VaultEntry[], resolutionKey: ResolutionKey): VaultEntry | undefined {
return entries.find((entry) => {
const lowerTitle = entry.title.toLowerCase()
return lowerTitle === resolutionKey.exactTarget || lowerTitle === resolutionKey.lastSegment
return lowerTitle === resolutionKey.exactTarget || lowerTitle === resolutionKey.targetWithoutWorkspace || lowerTitle === resolutionKey.lastSegment
})
}
@@ -142,13 +184,18 @@ function findEntryByHumanizedTitle(entries: VaultEntry[], resolutionKey: Resolut
* 4. Exact title match
* 5. Humanized title match (kebab-case → words)
*/
export function resolveEntry(entries: VaultEntry[], rawTarget: WikilinkTarget): VaultEntry | undefined {
const resolutionKey = buildResolutionKey(rawTarget)
export function resolveEntry(entries: VaultEntry[], rawTarget: WikilinkTarget, sourceEntry?: VaultEntry): VaultEntry | undefined {
const workspaceAliases = new Set(entries.map((entry) => workspaceForEntry(entry)?.alias.toLowerCase()).filter((alias): alias is string => !!alias))
const resolutionKey = buildResolutionKey(rawTarget, workspaceAliases)
const workspaceScopedEntries = filterEntriesByWorkspace(entries, resolutionKey.workspaceAlias)
const candidates = resolutionKey.workspaceAlias
? workspaceScopedEntries
: prioritizeSourceWorkspace(entries, sourceEntry)
return (
findEntryByPathSuffix(entries, resolutionKey)
?? findEntryByFilename(entries, resolutionKey)
?? findEntryByAlias(entries, resolutionKey)
?? findEntryByTitle(entries, resolutionKey)
?? findEntryByHumanizedTitle(entries, resolutionKey)
findEntryByPathSuffix(candidates, resolutionKey)
?? findEntryByFilename(candidates, resolutionKey)
?? findEntryByAlias(candidates, resolutionKey)
?? findEntryByTitle(candidates, resolutionKey)
?? findEntryByHumanizedTitle(candidates, resolutionKey)
)
}

86
src/utils/workspaces.ts Normal file
View File

@@ -0,0 +1,86 @@
import type { VaultEntry, WorkspaceIdentity } from '../types'
import type { VaultOption } from '../components/status-bar/types'
export const WORKSPACE_COLORS = ['blue', 'green', 'purple', 'orange', 'red', 'yellow'] as const
export type WorkspaceColor = typeof WORKSPACE_COLORS[number]
interface WorkspaceIdentityOptions {
defaultWorkspacePath?: string | null
}
function slugifyWorkspaceAlias(value: string): string {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
return normalized || 'workspace'
}
export function labelFromWorkspacePath(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() || 'Workspace'
}
function shortLabelFromLabel(label: string): string {
const words = label.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return 'W'
if (words.length === 1) return words[0].slice(0, 2).toUpperCase()
return words.slice(0, 2).map((word) => word[0]?.toUpperCase() ?? '').join('')
}
export function workspaceAliasFromOption(vault: Pick<VaultOption, 'alias' | 'label' | 'path'>): string {
return slugifyWorkspaceAlias(vault.alias || vault.label || labelFromWorkspacePath(vault.path))
}
export function workspaceIdentityFromVault(
vault: VaultOption,
options: WorkspaceIdentityOptions = {},
): WorkspaceIdentity {
const label = vault.label.trim() || labelFromWorkspacePath(vault.path)
const alias = workspaceAliasFromOption({ ...vault, label })
return {
id: alias,
label,
alias,
path: vault.path,
shortLabel: shortLabelFromLabel(label),
color: vault.color ?? null,
icon: vault.icon ?? null,
mounted: vault.mounted !== false,
available: vault.available !== false,
defaultForNewNotes: options.defaultWorkspacePath === vault.path,
}
}
export function workspaceForEntry(entry: Pick<VaultEntry, 'workspace' | 'path'>): WorkspaceIdentity | null {
return entry.workspace ?? null
}
export function workspacePathForEntry(entry: Pick<VaultEntry, 'workspace'>): string | undefined {
return entry.workspace?.path
}
export function workspaceLabelForEntry(entry: Pick<VaultEntry, 'workspace'>): string | null {
return entry.workspace?.label ?? null
}
export function workspaceDisplayPrefix(entry: Pick<VaultEntry, 'workspace'>): string | null {
const workspace = workspaceForEntry(entry)
return workspace ? `${workspace.label} / ` : null
}
export function mountedWorkspacePaths(vaults: VaultOption[]): string[] {
return vaults
.filter((vault) => vault.available !== false && vault.mounted !== false)
.map((vault) => vault.path)
}
export function workspacesMountedInGraph<T extends { path: string; available?: boolean; mounted?: boolean; managedDefault?: boolean }>(
vaults: T[],
activeVaultPath: string,
): T[] {
return vaults.filter((vault) => {
if (vault.path === activeVaultPath) return true
return vault.available !== false && vault.mounted !== false && vault.managedDefault !== true
})
}

View File

@@ -0,0 +1,214 @@
import { test, expect, type Page } from '@playwright/test'
import { sendShortcut } from './helpers'
const PRIMARY_VAULT_PATH = '/Users/mock/Field Notes'
const RESEARCH_VAULT_PATH = '/Users/mock/Research Lab'
const GETTING_STARTED_PATH = '/Users/mock/Getting Started'
const QUICK_OPEN_INPUT = 'input[placeholder="Search notes..."]'
const GLOBAL_SEARCH_INPUT = 'input[placeholder="Search in all notes..."]'
const SEARCH_NEEDLE = 'crossgraph-needle'
interface MockEntry {
path: string
filename: string
title: string
isA: string
aliases: string[]
belongsTo: string[]
relatedTo: string[]
status: string | null
archived: boolean
modifiedAt: number | null
createdAt: number | null
fileSize: number
snippet: string
wordCount: number
relationships: Record<string, string[]>
outgoingLinks: string[]
properties: Record<string, unknown>
template: null
sort: null
}
interface SearchResultData {
title: string
path: string
snippet: string
score: number
note_type: string | null
}
type MockHandler = (args?: Record<string, unknown>) => unknown
type MockWindow = Window & {
__mockHandlers?: Record<string, MockHandler>
__mountedWorkspaceSearchCalls?: string[]
}
interface MountedWorkspaceInitData {
contentByPathData: Record<string, string>
entriesByVaultData: Record<string, MockEntry[]>
gettingStartedPath: string
primaryVaultPath: string
researchVaultPath: string
}
function createMockEntry(vaultPath: string, filename: string, title: string, snippet: string): MockEntry {
return {
path: `${vaultPath}/${filename}`,
filename,
title,
isA: 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1_700_000_000,
createdAt: null,
fileSize: 256,
snippet,
wordCount: 12,
relationships: {},
outgoingLinks: [],
properties: {},
template: null,
sort: null,
}
}
async function installMountedWorkspaceMocks(page: Page): Promise<void> {
await page.route('**/api/vault/**', (route) => route.abort())
const primaryEntry = createMockEntry(
PRIMARY_VAULT_PATH,
'primary-signal.md',
'Primary Signal',
`Primary workspace has ${SEARCH_NEEDLE}`,
)
const researchEntry = createMockEntry(
RESEARCH_VAULT_PATH,
'research-beacon.md',
'Research Beacon',
`Research workspace has ${SEARCH_NEEDLE}`,
)
const entriesByVault: Record<string, MockEntry[]> = {
[PRIMARY_VAULT_PATH]: [primaryEntry],
[RESEARCH_VAULT_PATH]: [researchEntry],
}
const contentByPath = Object.fromEntries(
[primaryEntry, researchEntry].map((entry) => [entry.path, `# ${entry.title}\n\n${entry.snippet}`]),
)
await page.addInitScript((data: MountedWorkspaceInitData) => {
localStorage.clear()
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
const mockWindow = window as MockWindow
mockWindow.__mountedWorkspaceSearchCalls = []
let ref: Record<string, MockHandler> | undefined
const entriesByVaultData = data.entriesByVaultData
const contentByPathData = data.contentByPathData
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value: Record<string, MockHandler>) {
ref = value
ref.load_vault_list = () => ({
vaults: [
{
label: 'Field Notes',
path: data.primaryVaultPath,
alias: 'field',
color: 'blue',
icon: null,
mounted: true,
},
{
label: 'Research Lab',
path: data.researchVaultPath,
alias: 'lab',
color: 'green',
icon: null,
mounted: true,
},
],
active_vault: data.primaryVaultPath,
default_workspace_path: data.researchVaultPath,
hidden_defaults: [],
})
ref.save_vault_list = () => null
ref.get_default_vault_path = () => data.gettingStartedPath
ref.check_vault_exists = (args?: Record<string, unknown>) =>
args?.path === data.primaryVaultPath || args?.path === data.researchVaultPath
ref.list_vault = (args?: Record<string, unknown>) => entriesByVaultData[String(args?.path ?? '')] ?? []
ref.reload_vault = (args?: Record<string, unknown>) => entriesByVaultData[String(args?.path ?? '')] ?? []
ref.list_vault_folders = () => []
ref.list_views = () => []
ref.get_all_content = () => contentByPathData
ref.get_note_content = (args?: Record<string, unknown>) => contentByPathData[String(args?.path ?? '')] ?? ''
ref.get_modified_files = () => []
ref.get_file_history = () => []
ref.search_vault = (args?: Record<string, unknown>) => {
const vaultPath = String(args?.vaultPath ?? '')
mockWindow.__mountedWorkspaceSearchCalls?.push(vaultPath)
const query = String(args?.query ?? '').toLowerCase()
const results = (entriesByVaultData[vaultPath] ?? [])
.filter((entry) => `${entry.title} ${entry.snippet}`.toLowerCase().includes(query))
.map((entry): SearchResultData => ({
title: entry.title,
path: entry.path,
snippet: entry.snippet,
score: 1,
note_type: entry.isA,
}))
return { results, elapsed_ms: 1 }
}
},
get() {
return ref
},
})
}, {
contentByPathData: contentByPath,
entriesByVaultData: entriesByVault,
gettingStartedPath: GETTING_STARTED_PATH,
primaryVaultPath: PRIMARY_VAULT_PATH,
researchVaultPath: RESEARCH_VAULT_PATH,
})
}
async function openQuickOpen(page: Page): Promise<void> {
await page.locator('body').click()
await sendShortcut(page, 'p', ['Control'])
await expect(page.locator(QUICK_OPEN_INPUT)).toBeVisible()
}
async function openGlobalSearch(page: Page): Promise<void> {
await page.locator('body').click()
await sendShortcut(page, 'f', ['Control', 'Shift'])
await expect(page.locator(GLOBAL_SEARCH_INPUT)).toBeVisible()
}
test('mounted workspaces share the graph across note list, quick open, and search @smoke', async ({ page }) => {
await installMountedWorkspaceMocks(page)
await page.goto('/', { waitUntil: 'domcontentloaded' })
const noteList = page.getByTestId('note-list-container')
await expect(noteList.getByText('Primary Signal').first()).toBeVisible()
await expect(noteList.getByText('Research Beacon').first()).toBeVisible()
await expect(noteList.getByText('FN').first()).toBeVisible()
await expect(noteList.getByText('RL').first()).toBeVisible()
await openQuickOpen(page)
await page.locator(QUICK_OPEN_INPUT).fill('Beacon')
await expect(page.getByTestId('quick-open-palette').getByText('Research Lab / Research Beacon')).toBeVisible()
await page.keyboard.press('Escape')
await openGlobalSearch(page)
await page.locator(GLOBAL_SEARCH_INPUT).fill(SEARCH_NEEDLE)
await expect(page.getByText('Field Notes / Primary Signal')).toBeVisible({ timeout: 5_000 })
await expect(page.getByText('Research Lab / Research Beacon')).toBeVisible()
const searchCalls = await page.evaluate(() => (window as MockWindow).__mountedWorkspaceSearchCalls ?? [])
expect(new Set(searchCalls)).toEqual(new Set([PRIMARY_VAULT_PATH, RESEARCH_VAULT_PATH]))
})