feat: unify mounted workspace graph
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
40
docs/adr/0114-mounted-workspaces-unified-graph.md
Normal file
40
docs/adr/0114-mounted-workspaces-unified-graph.md
Normal 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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)?,
|
||||
|
||||
@@ -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![],
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
})
|
||||
|
||||
19
src/App.tsx
19
src/App.tsx
@@ -130,6 +130,7 @@ import {
|
||||
sanitizeSelectionForOrganization,
|
||||
} from './utils/organizationWorkflow'
|
||||
import { requestPlainTextPaste } from './utils/plainTextPaste'
|
||||
import { workspacesMountedInGraph } from './utils/workspaces'
|
||||
import './App.css'
|
||||
|
||||
const ACTIVE_EDITOR_SURFACE_SELECTOR = '.editor__blocknote-container, .raw-editor-codemirror'
|
||||
@@ -294,6 +295,7 @@ function App() {
|
||||
})
|
||||
const {
|
||||
allVaults,
|
||||
defaultWorkspacePath,
|
||||
registerVaultSelection,
|
||||
selectedVaultPath,
|
||||
syncVaultSelection,
|
||||
@@ -342,6 +344,16 @@ function App() {
|
||||
? onboarding.state.vaultPath
|
||||
: vaultSwitcher.vaultPath
|
||||
)
|
||||
const graphVaults = useMemo(
|
||||
() => noteWindowParams ? [] : workspacesMountedInGraph(allVaults, resolvedPath),
|
||||
[allVaults, noteWindowParams, resolvedPath],
|
||||
)
|
||||
const writableVaultPaths = useMemo(
|
||||
() => graphVaults
|
||||
.filter((workspace) => workspace.available !== false && workspace.mounted !== false)
|
||||
.map((workspace) => workspace.path),
|
||||
[graphVaults],
|
||||
)
|
||||
// Git repo check: 'checking' | 'missing' | 'ready'
|
||||
const [gitRepoState, setGitRepoState] = useState<'checking' | 'missing' | 'ready'>('checking')
|
||||
const [showGitSetupDialog, setShowGitSetupDialog] = useState(false)
|
||||
@@ -390,7 +402,7 @@ function App() {
|
||||
setToastMessage('Git initialized for this vault')
|
||||
}, [resolvedPath, setToastMessage])
|
||||
|
||||
const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath)
|
||||
const vault = useVaultLoader(noteWindowParams ? '' : resolvedPath, graphVaults, defaultWorkspacePath)
|
||||
const runtimeMissingVaultPath = !noteWindowParams ? vault.unavailableVaultPath : null
|
||||
const {
|
||||
markInternalWrite: markRecentVaultWrite,
|
||||
@@ -609,6 +621,8 @@ function App() {
|
||||
setToastMessage,
|
||||
updateEntry: vault.updateEntry,
|
||||
vaultPath: resolvedPath,
|
||||
defaultWorkspacePath: noteWindowParams ? null : defaultWorkspacePath,
|
||||
vaults: graphVaults,
|
||||
addPendingSave: handleCreatedVaultEntryPersisting,
|
||||
removePendingSave: vault.removePendingSave,
|
||||
trackUnsaved: vault.trackUnsaved,
|
||||
@@ -827,6 +841,7 @@ function App() {
|
||||
tabs: notes.tabs, activeTabPath: notes.activeTabPath,
|
||||
handleRenameNote: notes.handleRenameNote, handleRenameFilename: notes.handleRenameFilename,
|
||||
replaceEntry: vault.replaceEntry, resolvedPath,
|
||||
writableVaultPaths,
|
||||
initialH1AutoRenameEnabled: settings.initial_h1_auto_rename_enabled !== false,
|
||||
onInternalVaultWrite: markRecentVaultWrite,
|
||||
locale: appLocale,
|
||||
@@ -1817,7 +1832,7 @@ function App() {
|
||||
</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)} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
}) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
29
src/hooks/useWorkspaceIdentityActions.ts
Normal file
29
src/hooks/useWorkspaceIdentityActions.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
29
src/hooks/vaultSwitcherSanitization.ts
Normal file
29
src/hooks/vaultSwitcherSanitization.ts
Normal 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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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} 앞서 있음",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} опережает удаленный репозиторий",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
14
src/types.ts
14
src/types.ts
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
86
src/utils/workspaces.ts
Normal 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
|
||||
})
|
||||
}
|
||||
214
tests/smoke/mounted-workspaces-unified-graph.spec.ts
Normal file
214
tests/smoke/mounted-workspaces-unified-graph.spec.ts
Normal 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]))
|
||||
})
|
||||
Reference in New Issue
Block a user