2026-03-04 19:53:46 +01:00
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
2026-02-14 18:22:42 +01:00
|
|
|
import { Sidebar } from './components/Sidebar'
|
|
|
|
|
import { NoteList } from './components/NoteList'
|
2026-04-07 20:09:04 +02:00
|
|
|
import type { DeletedNoteEntry } from './components/note-list/noteListUtils'
|
2026-02-14 18:22:42 +01:00
|
|
|
import { Editor } from './components/Editor'
|
|
|
|
|
import { ResizeHandle } from './components/ResizeHandle'
|
2026-02-21 09:41:46 +01:00
|
|
|
import { CreateTypeDialog } from './components/CreateTypeDialog'
|
2026-04-02 20:54:06 +02:00
|
|
|
import { CreateViewDialog } from './components/CreateViewDialog'
|
2026-02-14 21:17:38 +01:00
|
|
|
import { QuickOpenPalette } from './components/QuickOpenPalette'
|
2026-02-24 23:41:54 +01:00
|
|
|
import { CommandPalette } from './components/CommandPalette'
|
feat: full-text search with qmd backend and SearchPanel UI (Cmd+Shift+F) (#64)
* feat: add search backend (qmd CLI) and design file
- Add search.rs: qmd integration for keyword (BM25), semantic (vsearch),
and hybrid search modes via CLI JSON output
- Register search_vault Tauri command in lib.rs
- Design file with 3 frames: empty, results, no-results states
- Fix pre-existing test failures: install @tauri-apps/plugin-opener,
add mock in test setup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add SearchPanel UI with Cmd+Shift+F shortcut
- SearchPanel component: full-text search overlay with keyword/semantic
mode toggle, debounced search (200ms), arrow key navigation, result
count + elapsed time display, note type badges from vault entries
- Cmd+Shift+F shortcut registered in useAppKeyboard
- Mock search_vault handler in mock-tauri for browser dev mode
- SearchResult/SearchResponse types in types.ts
- Tests: 15 new tests for SearchPanel + 2 for keyboard shortcut
- scrollIntoView mock in test setup for jsdom compatibility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: correct SearchPanel imports for Tauri/browser dual-mode
Use invoke from @tauri-apps/api/core and mockInvoke from mock-tauri
with a searchCall wrapper, fixing the TypeScript build error.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: make test_detect_collection_fallback robust when qmd not installed
* fix: reset localStorage between App tests to prevent view mode state leak
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:09:50 +01:00
|
|
|
import { SearchPanel } from './components/SearchPanel'
|
2026-02-14 21:19:38 +01:00
|
|
|
import { Toast } from './components/Toast'
|
2026-02-15 13:00:10 +01:00
|
|
|
import { CommitDialog } from './components/CommitDialog'
|
2026-03-05 16:27:29 +01:00
|
|
|
import { PulseView } from './components/PulseView'
|
2026-02-17 11:03:23 +01:00
|
|
|
import { StatusBar } from './components/StatusBar'
|
2026-02-22 13:38:18 +01:00
|
|
|
import { SettingsPanel } from './components/SettingsPanel'
|
2026-04-12 17:08:07 +02:00
|
|
|
import { CloneVaultModal } from './components/CloneVaultModal'
|
feat: integrate welcome screen with tests and fix App tests
WelcomeScreen component (14 tests):
- welcome mode: title, buttons, hint, loading, error states
- vault-missing mode: path badge, different button labels
useOnboarding hook (10 tests):
- vault exists → ready, vault missing → welcome
- dismissed + missing → vault-missing
- create vault, open folder, dismiss transitions
- error handling, picker cancellation, command failure fallback
App.test.tsx updated to mock new Tauri commands
(check_vault_exists, get_default_vault_path).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:55:35 +01:00
|
|
|
import { WelcomeScreen } from './components/WelcomeScreen'
|
2026-04-13 19:37:59 +02:00
|
|
|
import { AiAgentsOnboardingPrompt } from './components/AiAgentsOnboardingPrompt'
|
2026-03-25 16:10:39 +01:00
|
|
|
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
|
2026-04-10 12:45:37 +02:00
|
|
|
import { FeedbackDialog } from './components/FeedbackDialog'
|
2026-03-25 16:20:09 +01:00
|
|
|
import { useTelemetry } from './hooks/useTelemetry'
|
2026-03-04 13:11:58 +01:00
|
|
|
import { useMcpStatus } from './hooks/useMcpStatus'
|
2026-04-13 19:37:59 +02:00
|
|
|
import { useAiAgentsOnboarding } from './hooks/useAiAgentsOnboarding'
|
|
|
|
|
import { useAiAgentsStatus } from './hooks/useAiAgentsStatus'
|
2026-04-14 14:16:55 +02:00
|
|
|
import { useVaultAiGuidanceStatus } from './hooks/useVaultAiGuidanceStatus'
|
2026-04-16 23:38:30 +02:00
|
|
|
import { useAutoGit } from './hooks/useAutoGit'
|
2026-02-17 12:10:21 +01:00
|
|
|
import { useVaultLoader } from './hooks/useVaultLoader'
|
2026-04-13 19:37:59 +02:00
|
|
|
import { useAiAgentPreferences } from './hooks/useAiAgentPreferences'
|
2026-02-22 13:38:18 +01:00
|
|
|
import { useSettings } from './hooks/useSettings'
|
2026-03-16 23:34:45 +01:00
|
|
|
import { useNoteActions } from './hooks/useNoteActions'
|
2026-04-17 00:44:14 +02:00
|
|
|
import { slugify } from './hooks/useNoteCreation'
|
2026-02-23 20:34:12 +01:00
|
|
|
import { useCommitFlow } from './hooks/useCommitFlow'
|
2026-04-12 19:38:25 +02:00
|
|
|
import { useGitRemoteStatus } from './hooks/useGitRemoteStatus'
|
2026-04-16 05:03:01 +02:00
|
|
|
import { useViewMode, type ViewMode } from './hooks/useViewMode'
|
2026-02-22 10:48:13 +01:00
|
|
|
import { useEntryActions } from './hooks/useEntryActions'
|
2026-02-25 13:31:19 +01:00
|
|
|
import { useAppCommands } from './hooks/useAppCommands'
|
2026-04-18 09:58:49 +02:00
|
|
|
import { triggerCommitEntryAction } from './utils/commitEntryAction'
|
2026-03-30 16:30:54 +02:00
|
|
|
import { generateCommitMessage } from './utils/commitMessage'
|
2026-02-25 13:31:19 +01:00
|
|
|
import { useDialogs } from './hooks/useDialogs'
|
2026-04-18 00:21:52 +02:00
|
|
|
import { useVaultSwitcher } from './hooks/useVaultSwitcher'
|
2026-02-25 13:31:19 +01:00
|
|
|
import { useGitHistory } from './hooks/useGitHistory'
|
2026-03-03 16:46:47 +01:00
|
|
|
import { useUpdater, restartApp } from './hooks/useUpdater'
|
feat: auto-pull vault changes from Git (background sync + conflict handling) (#79)
* feat: add auto-pull vault sync with conflict handling
- Add git_pull, has_remote, get_conflict_files to Rust backend
- Add GitPullResult type and auto_pull_interval_minutes to Settings
- Create useAutoSync hook (pull on launch, focus, periodic interval)
- Update StatusBar with real sync status indicator
- Add pull interval setting to SettingsPanel
- Add mock handler for git_pull
- Update existing tests for new Settings field
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: add tests for git pull, settings, and useAutoSync hook
- Add Rust tests: has_remote, git_pull (no_remote, up_to_date, updated),
get_conflict_files, parse_updated_files, GitPullResult serialization
- Add frontend tests: useAutoSync (mount pull, focus pull, conflict,
error, manual trigger, concurrent prevention, no_remote)
- Fix existing settings tests for new auto_pull_interval_minutes field
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* design: add auto-pull-vault wireframes
Frames showing: sync idle, syncing, conflict indicator,
settings sync section, and conflict toast notification.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add auto_pull_interval_minutes to all Settings literals
Fix build error and test fixtures missing the new field.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: cargo fmt
* ci: re-trigger CI after flaky test
* ci: retrigger after disk space cleanup
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 01:55:16 +01:00
|
|
|
import { useAutoSync } from './hooks/useAutoSync'
|
2026-03-03 02:27:42 +01:00
|
|
|
import { useConflictResolver } from './hooks/useConflictResolver'
|
2026-02-28 12:41:57 +01:00
|
|
|
import { useZoom } from './hooks/useZoom'
|
2026-03-05 15:26:21 +01:00
|
|
|
import { useVaultConfig } from './hooks/useVaultConfig'
|
2026-03-02 01:50:41 +01:00
|
|
|
import { useBuildNumber } from './hooks/useBuildNumber'
|
feat: integrate welcome screen with tests and fix App tests
WelcomeScreen component (14 tests):
- welcome mode: title, buttons, hint, loading, error states
- vault-missing mode: path badge, different button labels
useOnboarding hook (10 tests):
- vault exists → ready, vault missing → welcome
- dismissed + missing → vault-missing
- create vault, open folder, dismiss transitions
- error handling, picker cancellation, command failure fallback
App.test.tsx updated to mock new Tauri commands
(check_vault_exists, get_default_vault_path).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:55:35 +01:00
|
|
|
import { useOnboarding } from './hooks/useOnboarding'
|
2026-04-15 14:55:56 +02:00
|
|
|
import { useGettingStartedClone } from './hooks/useGettingStartedClone'
|
2026-04-12 19:17:49 +02:00
|
|
|
import { useNetworkStatus } from './hooks/useNetworkStatus'
|
2026-03-12 07:24:46 +01:00
|
|
|
import { useAppNavigation } from './hooks/useAppNavigation'
|
2026-04-16 05:03:01 +02:00
|
|
|
import {
|
|
|
|
|
applyMainWindowSizeConstraints,
|
|
|
|
|
getMainWindowMinWidth,
|
|
|
|
|
useMainWindowSizeConstraints,
|
|
|
|
|
} from './hooks/useMainWindowSizeConstraints'
|
2026-03-04 10:05:43 +01:00
|
|
|
import { useAiActivity } from './hooks/useAiActivity'
|
2026-03-12 03:19:32 +01:00
|
|
|
import { useBulkActions } from './hooks/useBulkActions'
|
2026-03-12 06:28:19 +01:00
|
|
|
import { useDeleteActions } from './hooks/useDeleteActions'
|
2026-03-13 07:11:31 +01:00
|
|
|
import { useLayoutPanels } from './hooks/useLayoutPanels'
|
2026-03-29 10:35:13 +02:00
|
|
|
import { useConflictFlow } from './hooks/useConflictFlow'
|
|
|
|
|
import { useAppSave } from './hooks/useAppSave'
|
|
|
|
|
import { useVaultBridge } from './hooks/useVaultBridge'
|
2026-04-15 17:57:38 +02:00
|
|
|
import type { CommitDiffRequest } from './hooks/useDiffMode'
|
2026-03-03 02:27:42 +01:00
|
|
|
import { ConflictResolverModal } from './components/ConflictResolverModal'
|
2026-03-12 00:06:33 +01:00
|
|
|
import { ConfirmDeleteDialog } from './components/ConfirmDeleteDialog'
|
2026-04-14 10:53:08 +02:00
|
|
|
import { DeleteProgressNotice } from './components/DeleteProgressNotice'
|
feat: auto-build, GitHub Release, and in-app updater (#14)
* ci: auto-release workflow on merge to main
Rewrite .github/workflows/release.yml to trigger on every push to main
instead of manual tag pushes. The workflow now:
- Computes version as 0.YYYYMMDD.GITHUB_RUN_NUMBER
- Builds aarch64-apple-darwin and x86_64-apple-darwin in parallel
- Merges them into a universal binary using lipo
- Creates a universal .dmg and signed updater tarball
- Generates latest.json with per-arch and universal platform entries
- Publishes a GitHub Release with auto-generated release notes
- Updates a GitHub Pages release history site (gh-pages branch)
Product decisions:
- Universal binary approach: copy arm64 .app as base, lipo the main
executable, keep everything else from arm64 (shared frameworks are
architecture-independent). This is the standard Tauri pattern.
- Per-arch updater tarballs are also uploaded so the Tauri updater can
download the correct arch-specific build (smaller download).
- Release notes are auto-generated from git log since last tag.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* ci: github pages with release history
Use peaceiris/actions-gh-pages@v4 to deploy a release history site.
The page fetches releases.json (also deployed) and renders each release
with date, notes, and download links for .dmg files.
This handles the gh-pages branch creation automatically on first run.
The page is available at https://refactoringhq.github.io/laputa-app/
Product decision: used fetch() to load releases.json at runtime instead
of inlining it, which is cleaner and avoids shell escaping issues with
release note content. The releases.json is deployed alongside index.html.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: in-app update notification UI
Replace the old window.confirm updater with a proper React-based
update notification system:
- useUpdater hook now exposes state machine (idle → available →
downloading → ready) and actions (startDownload, openReleaseNotes,
dismiss)
- UpdateBanner component renders at the top of the app shell:
- "Available" state: shows version, Release Notes link, Update Now
button, dismiss X
- "Downloading" state: animated spinner, progress bar with percentage
- "Ready" state: Restart Now button to apply the update
- Silently checks on startup after 3s delay; fails silently on
network errors or 404
- Release Notes link opens the GitHub Pages release history site
Product decisions:
- Banner at top of app (not a modal) — non-intrusive, visible but
not blocking. User can dismiss and continue working.
- Progress bar shows during download so user knows it's working.
- Separate "Restart Now" state after download so user controls when
the app restarts (they may have unsaved work).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: updater component tests
Rewrite useUpdater hook tests and add UpdateBanner component tests:
Hook tests (10 cases):
- Starts in idle state
- Does nothing when not in Tauri
- Checks for updates after 3s delay
- Stays idle when no update available
- Transitions to available when update found
- Handles missing release body gracefully
- Stays idle on network error (fails silently)
- Dismiss returns to idle
- openReleaseNotes opens correct URL
- startDownload transitions through downloading to ready
Component tests (10 cases):
- Renders nothing when idle
- Renders nothing on error
- Shows version and buttons when available
- Update Now calls startDownload
- Release Notes calls openReleaseNotes
- Dismiss button works
- Shows progress bar during download
- Shows 0% at start of download
- Shows restart button when ready
- Restart button calls restartApp
All 457 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* design: auto-build-release wireframes
Copy ui-design.pen as base. Frames to be added for:
1. Update notification banner (visible state) — horizontal bar at
top of app shell with version text, Release Notes link, Update
Now button, and dismiss X
2. Update download progress state — spinner icon, progress bar with
percentage, downloading text
3. "Restart to apply" state — green accent, version text, Restart
Now button
Note: Pencil editor was not available during this session. The base
design file is committed; frames will be added when the editor is
accessible. The implemented component (UpdateBanner.tsx) serves as
the source of truth for the design.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update ARCHITECTURE.md with release/update system
Add comprehensive documentation for:
- Release pipeline (4-phase workflow: version → build → release → pages)
- Versioning scheme (0.YYYYMMDD.RUN_NUMBER)
- Universal binary strategy (lipo merge)
- Updater endpoint and latest.json manifest
- In-app update UI state machine
- GitHub Pages release history site
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: rustfmt formatting
* fix: rustfmt build.rs
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:50:36 +01:00
|
|
|
import { UpdateBanner } from './components/UpdateBanner'
|
2026-03-03 01:37:25 +01:00
|
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
|
|
|
import { isTauri, mockInvoke } from './mock-tauri'
|
2026-04-18 20:31:25 +02:00
|
|
|
import type { SidebarSelection, InboxPeriod, VaultEntry, ViewDefinition } from './types'
|
2026-03-04 19:53:46 +01:00
|
|
|
import type { NoteListItem } from './utils/ai-context'
|
2026-04-19 18:18:35 +02:00
|
|
|
import { initializeNoteProperties } from './utils/initializeNoteProperties'
|
2026-03-19 06:41:40 +01:00
|
|
|
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
|
2026-03-19 08:47:25 +01:00
|
|
|
import { openNoteInNewWindow } from './utils/openNoteWindow'
|
2026-04-20 14:43:52 +02:00
|
|
|
import { refreshPulledVaultState } from './utils/pulledVaultRefresh'
|
2026-04-14 18:17:45 +02:00
|
|
|
import { isNoteWindow, getNoteWindowParams, getNoteWindowPathCandidates, findNoteWindowEntry, type NoteWindowParams } from './utils/windowMode'
|
2026-03-31 11:30:39 +02:00
|
|
|
import { GitRequiredModal } from './components/GitRequiredModal'
|
2026-03-31 11:58:32 +02:00
|
|
|
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
|
2026-04-07 20:31:08 +02:00
|
|
|
import { openNoteListPropertiesPicker } from './components/note-list/noteListPropertiesEvents'
|
2026-04-16 01:36:44 +02:00
|
|
|
import type { NoteListMultiSelectionCommands } from './components/note-list/multiSelectionCommands'
|
2026-04-07 21:09:06 +02:00
|
|
|
import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents'
|
feat: implement PostHog event tracking plan
Add trackEvent() calls for all user actions in the tracking plan:
- Vault: vault_opened (with has_git, note_count), vault_switched
- Notes: note_created (with has_type, creation_path), note_deleted,
note_trashed, note_archived, note_favorited, note_unfavorited
- Git: commit_made, sync_triggered
- Search: search_used
- Editor: raw_mode_toggled, wikilink_inserted
- Types & Views: type_created, view_created
- Telemetry: telemetry_opted_in, telemetry_opted_out
All events gated by PostHog initialization (only fires when opted in).
No PII in any event properties — counts, booleans, and enums only.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:42:43 +02:00
|
|
|
import { trackEvent } from './lib/telemetry'
|
2026-04-14 14:16:55 +02:00
|
|
|
import {
|
|
|
|
|
buildVaultAiGuidanceRefreshKey,
|
|
|
|
|
} from './lib/vaultAiGuidance'
|
2026-04-07 20:09:04 +02:00
|
|
|
import { extractDeletedContentFromDiff } from './components/note-list/noteListUtils'
|
2026-04-07 21:09:06 +02:00
|
|
|
import { hasNoteIconValue } from './utils/noteIcon'
|
2026-04-15 17:57:38 +02:00
|
|
|
import { filenameStemToTitle } from './utils/noteTitle'
|
2026-04-19 18:46:10 +02:00
|
|
|
import {
|
|
|
|
|
focusNoteListContainer,
|
|
|
|
|
isEditableElement,
|
|
|
|
|
isEditorEscapeTarget,
|
|
|
|
|
popNeighborhoodHistory,
|
|
|
|
|
pushNeighborhoodHistory,
|
|
|
|
|
shouldProcessNeighborhoodEscape,
|
|
|
|
|
} from './utils/neighborhoodHistory'
|
2026-04-12 13:14:18 +02:00
|
|
|
import { OPEN_AI_CHAT_EVENT } from './utils/aiPromptBridge'
|
2026-04-10 13:52:39 +02:00
|
|
|
import {
|
|
|
|
|
INBOX_SELECTION,
|
|
|
|
|
isExplicitOrganizationEnabled,
|
|
|
|
|
sanitizeSelectionForOrganization,
|
|
|
|
|
} from './utils/organizationWorkflow'
|
2026-02-14 18:20:07 +01:00
|
|
|
import './App.css'
|
|
|
|
|
|
2026-03-07 00:35:35 +01:00
|
|
|
// Type declarations for mock content storage and test overrides
|
2026-02-15 12:54:11 +01:00
|
|
|
declare global {
|
|
|
|
|
interface Window {
|
|
|
|
|
__mockContent?: Record<string, string>
|
2026-03-07 00:35:35 +01:00
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map for Playwright test overrides
|
|
|
|
|
__mockHandlers?: Record<string, (args: any) => any>
|
2026-02-15 12:54:11 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 13:52:39 +02:00
|
|
|
const DEFAULT_SELECTION: SidebarSelection = INBOX_SELECTION
|
2026-02-14 19:35:10 +01:00
|
|
|
|
2026-04-18 16:02:19 +02:00
|
|
|
function shouldPreferOnboardingVaultPath(
|
|
|
|
|
onboardingState: { status: string; vaultPath?: string },
|
|
|
|
|
vaults: Array<{ path: string }>,
|
|
|
|
|
): onboardingState is { status: 'ready'; vaultPath: string } {
|
2026-04-18 15:59:34 +02:00
|
|
|
return onboardingState.status === 'ready'
|
|
|
|
|
&& typeof onboardingState.vaultPath === 'string'
|
|
|
|
|
&& onboardingState.vaultPath.length > 0
|
|
|
|
|
&& !vaults.some((vault) => vault.path === onboardingState.vaultPath)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 18:17:45 +02:00
|
|
|
async function resolveNoteWindowEntry(
|
|
|
|
|
noteWindowParams: NoteWindowParams,
|
|
|
|
|
entries: VaultEntry[],
|
|
|
|
|
): Promise<VaultEntry | undefined> {
|
|
|
|
|
const fallbackEntry = () =>
|
|
|
|
|
findNoteWindowEntry(entries, noteWindowParams)
|
|
|
|
|
|
|
|
|
|
if (!isTauri()) {
|
|
|
|
|
return fallbackEntry()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const path of getNoteWindowPathCandidates(noteWindowParams)) {
|
|
|
|
|
try {
|
|
|
|
|
return await invoke<VaultEntry>('reload_vault_entry', { path })
|
|
|
|
|
} catch {
|
|
|
|
|
// Try the next normalized candidate before falling back to the scanned entries.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return fallbackEntry()
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-15 17:57:38 +02:00
|
|
|
function createPulseDeletedNoteEntry(fullPath: string, relativePath: string): DeletedNoteEntry {
|
|
|
|
|
const filename = relativePath.split('/').pop() ?? relativePath
|
|
|
|
|
return {
|
|
|
|
|
path: fullPath,
|
|
|
|
|
filename,
|
|
|
|
|
title: filenameStemToTitle(filename),
|
|
|
|
|
isA: 'Note',
|
|
|
|
|
aliases: [],
|
|
|
|
|
belongsTo: [],
|
|
|
|
|
relatedTo: [],
|
|
|
|
|
status: null,
|
|
|
|
|
archived: false,
|
|
|
|
|
modifiedAt: null,
|
|
|
|
|
createdAt: null,
|
|
|
|
|
fileSize: 0,
|
|
|
|
|
snippet: '',
|
|
|
|
|
wordCount: 0,
|
|
|
|
|
relationships: {},
|
|
|
|
|
icon: null,
|
|
|
|
|
color: null,
|
|
|
|
|
order: null,
|
|
|
|
|
sidebarLabel: null,
|
|
|
|
|
template: null,
|
|
|
|
|
sort: null,
|
|
|
|
|
view: null,
|
|
|
|
|
visible: null,
|
|
|
|
|
organized: false,
|
|
|
|
|
favorite: false,
|
|
|
|
|
favoriteIndex: null,
|
|
|
|
|
listPropertiesDisplay: [],
|
|
|
|
|
outgoingLinks: [],
|
|
|
|
|
properties: {},
|
|
|
|
|
hasH1: true,
|
|
|
|
|
fileKind: 'markdown',
|
|
|
|
|
__deletedNotePreview: true,
|
|
|
|
|
__deletedRelativePath: relativePath,
|
|
|
|
|
__changeAddedLines: null,
|
|
|
|
|
__changeDeletedLines: null,
|
|
|
|
|
__changeBinary: false,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 15:04:49 +01:00
|
|
|
/** Wraps useEditorSave to also keep outgoingLinks in sync on save and on content change. */
|
2026-02-22 10:48:13 +01:00
|
|
|
function App() {
|
2026-03-30 18:30:38 +02:00
|
|
|
const noteWindowParams = useMemo(() => isNoteWindow() ? getNoteWindowParams() : null, [])
|
2026-02-22 10:48:13 +01:00
|
|
|
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
|
2026-03-18 03:43:23 +01:00
|
|
|
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
|
2026-04-19 18:46:10 +02:00
|
|
|
const selectionRef = useRef<SidebarSelection>(DEFAULT_SELECTION)
|
|
|
|
|
const neighborhoodHistoryRef = useRef<SidebarSelection[]>([])
|
2026-04-04 11:03:50 +02:00
|
|
|
const inboxPeriod: InboxPeriod = 'all'
|
2026-04-19 18:46:10 +02:00
|
|
|
const handleSetSelection = useCallback((sel: SidebarSelection, options?: { preserveNeighborhoodHistory?: boolean }) => {
|
|
|
|
|
if (!options?.preserveNeighborhoodHistory && sel.kind !== 'entity') {
|
|
|
|
|
neighborhoodHistoryRef.current = []
|
|
|
|
|
}
|
2026-03-18 03:43:23 +01:00
|
|
|
setSelection(sel)
|
|
|
|
|
setNoteListFilter('open')
|
|
|
|
|
}, [])
|
2026-04-19 03:37:33 +02:00
|
|
|
const handleEnterNeighborhood = useCallback((entry: VaultEntry) => {
|
2026-04-19 18:46:10 +02:00
|
|
|
const nextSelection: SidebarSelection = { kind: 'entity', entry }
|
|
|
|
|
neighborhoodHistoryRef.current = pushNeighborhoodHistory(
|
|
|
|
|
neighborhoodHistoryRef.current,
|
|
|
|
|
selectionRef.current,
|
|
|
|
|
nextSelection,
|
|
|
|
|
)
|
|
|
|
|
handleSetSelection(nextSelection, { preserveNeighborhoodHistory: true })
|
2026-04-19 03:37:33 +02:00
|
|
|
}, [handleSetSelection])
|
2026-03-30 18:30:38 +02:00
|
|
|
const layout = useLayoutPanels(noteWindowParams ? { initialInspectorCollapsed: true } : undefined)
|
2026-04-07 21:09:06 +02:00
|
|
|
const { setInspectorCollapsed } = layout
|
2026-04-06 10:29:27 +02:00
|
|
|
const visibleNotesRef = useRef<VaultEntry[]>([])
|
2026-04-16 01:36:44 +02:00
|
|
|
const multiSelectionCommandRef = useRef<NoteListMultiSelectionCommands | null>(null)
|
2026-02-14 21:19:38 +01:00
|
|
|
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
2026-02-25 13:31:19 +01:00
|
|
|
const dialogs = useDialogs()
|
2026-04-12 13:14:18 +02:00
|
|
|
const { showAIChat, toggleAIChat } = dialogs
|
2026-04-10 12:45:37 +02:00
|
|
|
const [showFeedback, setShowFeedback] = useState(false)
|
|
|
|
|
const openFeedback = useCallback(() => setShowFeedback(true), [])
|
|
|
|
|
const closeFeedback = useCallback(() => setShowFeedback(false), [])
|
2026-04-12 19:17:49 +02:00
|
|
|
const networkStatus = useNetworkStatus()
|
2026-02-25 13:31:19 +01:00
|
|
|
|
2026-04-12 13:14:18 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
const handleOpenAiChat = () => {
|
|
|
|
|
if (!showAIChat) toggleAIChat()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.addEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat)
|
|
|
|
|
return () => window.removeEventListener(OPEN_AI_CHAT_EVENT, handleOpenAiChat)
|
|
|
|
|
}, [showAIChat, toggleAIChat])
|
|
|
|
|
|
2026-02-25 13:31:19 +01:00
|
|
|
// onSwitch closure captures `notes` declared below — safe because it's only
|
|
|
|
|
// called on user interaction, never during render (refs inside the hook
|
|
|
|
|
// guarantee the latest closure is always used).
|
|
|
|
|
const vaultSwitcher = useVaultSwitcher({
|
2026-03-18 03:43:23 +01:00
|
|
|
onSwitch: () => { handleSetSelection(DEFAULT_SELECTION); notes.closeAllTabs() },
|
2026-02-25 13:31:19 +01:00
|
|
|
onToast: (msg) => setToastMessage(msg),
|
|
|
|
|
})
|
2026-04-18 03:10:41 +02:00
|
|
|
const { allVaults, defaultPath, handleVaultCloned, selectedVaultPath, switchVault } = vaultSwitcher
|
2026-04-15 14:55:56 +02:00
|
|
|
|
2026-04-18 00:21:52 +02:00
|
|
|
const rememberOnboardingVaultChoice = useCallback((vaultPath: string) => {
|
|
|
|
|
if (!vaultPath) return
|
|
|
|
|
|
|
|
|
|
if (allVaults.some((vault) => vault.path === vaultPath)) {
|
|
|
|
|
switchVault(vaultPath)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const label = vaultPath.split('/').filter(Boolean).pop() || 'Local Vault'
|
2026-04-15 14:55:56 +02:00
|
|
|
handleVaultCloned(vaultPath, label)
|
2026-04-18 00:21:52 +02:00
|
|
|
}, [allVaults, handleVaultCloned, switchVault])
|
|
|
|
|
|
|
|
|
|
const handleGettingStartedVaultReady = useCallback((vaultPath: string) => {
|
|
|
|
|
rememberOnboardingVaultChoice(vaultPath)
|
2026-04-15 14:55:56 +02:00
|
|
|
setToastMessage(`Getting Started vault cloned and opened at ${vaultPath}`)
|
2026-04-18 00:21:52 +02:00
|
|
|
}, [rememberOnboardingVaultChoice])
|
2026-04-15 14:55:56 +02:00
|
|
|
const cloneGettingStartedVault = useGettingStartedClone({
|
|
|
|
|
onError: (message) => setToastMessage(message),
|
|
|
|
|
onSuccess: handleGettingStartedVaultReady,
|
|
|
|
|
})
|
|
|
|
|
const onboarding = useOnboarding(vaultSwitcher.vaultPath, (vaultPath) => {
|
2026-04-18 00:21:52 +02:00
|
|
|
handleGettingStartedVaultReady(vaultPath)
|
2026-04-19 20:08:10 +02:00
|
|
|
}, vaultSwitcher.loaded)
|
2026-04-13 19:37:59 +02:00
|
|
|
const aiAgentsStatus = useAiAgentsStatus()
|
|
|
|
|
const aiAgentsOnboarding = useAiAgentsOnboarding(onboarding.state.status === 'ready' && !noteWindowParams)
|
2026-04-18 15:59:34 +02:00
|
|
|
const lastHandledOnboardingUserVaultPathRef = useRef<string | null>(null)
|
feat: integrate welcome screen with tests and fix App tests
WelcomeScreen component (14 tests):
- welcome mode: title, buttons, hint, loading, error states
- vault-missing mode: path badge, different button labels
useOnboarding hook (10 tests):
- vault exists → ready, vault missing → welcome
- dismissed + missing → vault-missing
- create vault, open folder, dismiss transitions
- error handling, picker cancellation, command failure fallback
App.test.tsx updated to mock new Tauri commands
(check_vault_exists, get_default_vault_path).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:55:35 +01:00
|
|
|
|
2026-04-18 00:21:52 +02:00
|
|
|
useEffect(() => {
|
2026-04-18 15:59:34 +02:00
|
|
|
const onboardingVaultPath = onboarding.userReadyVaultPath
|
|
|
|
|
if (!onboardingVaultPath || lastHandledOnboardingUserVaultPathRef.current === onboardingVaultPath) return
|
|
|
|
|
|
|
|
|
|
lastHandledOnboardingUserVaultPathRef.current = onboardingVaultPath
|
|
|
|
|
if (onboardingVaultPath !== vaultSwitcher.vaultPath) {
|
|
|
|
|
rememberOnboardingVaultChoice(onboardingVaultPath)
|
|
|
|
|
}
|
|
|
|
|
}, [onboarding.userReadyVaultPath, rememberOnboardingVaultChoice, vaultSwitcher.vaultPath])
|
2026-04-18 00:21:52 +02:00
|
|
|
|
2026-04-18 15:59:34 +02:00
|
|
|
// Onboarding can briefly own the vault path for a newly created/opened vault
|
|
|
|
|
// before the persisted switcher catches up, but once the path is already in
|
|
|
|
|
// the switcher list we should trust the explicit switcher state.
|
|
|
|
|
const resolvedPath = noteWindowParams?.vaultPath ?? (
|
2026-04-18 16:02:19 +02:00
|
|
|
shouldPreferOnboardingVaultPath(onboarding.state, vaultSwitcher.allVaults)
|
2026-04-18 15:59:34 +02:00
|
|
|
? onboarding.state.vaultPath
|
|
|
|
|
: vaultSwitcher.vaultPath
|
|
|
|
|
)
|
2026-03-31 11:30:39 +02:00
|
|
|
// Git repo check: 'checking' | 'required' | 'ready'
|
|
|
|
|
const [gitRepoState, setGitRepoState] = useState<'checking' | 'required' | 'ready'>('checking')
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!resolvedPath) return
|
|
|
|
|
setGitRepoState('checking')
|
|
|
|
|
const check = isTauri()
|
|
|
|
|
? invoke<boolean>('is_git_repo', { vaultPath: resolvedPath })
|
|
|
|
|
: Promise.resolve(true) // browser mock: assume git
|
|
|
|
|
check
|
|
|
|
|
.then(isGit => setGitRepoState(isGit ? 'ready' : 'required'))
|
|
|
|
|
.catch(() => setGitRepoState('ready')) // fail open
|
|
|
|
|
}, [resolvedPath])
|
|
|
|
|
|
|
|
|
|
const handleInitGitRepo = useCallback(async () => {
|
|
|
|
|
if (isTauri()) await invoke('init_git_repo', { vaultPath: resolvedPath })
|
|
|
|
|
setGitRepoState('ready')
|
|
|
|
|
}, [resolvedPath])
|
|
|
|
|
|
feat: integrate welcome screen with tests and fix App tests
WelcomeScreen component (14 tests):
- welcome mode: title, buttons, hint, loading, error states
- vault-missing mode: path badge, different button labels
useOnboarding hook (10 tests):
- vault exists → ready, vault missing → welcome
- dismissed + missing → vault-missing
- create vault, open folder, dismiss transitions
- error handling, picker cancellation, command failure fallback
App.test.tsx updated to mock new Tauri commands
(check_vault_exists, get_default_vault_path).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:55:35 +01:00
|
|
|
const vault = useVaultLoader(resolvedPath)
|
2026-04-14 14:16:55 +02:00
|
|
|
const {
|
|
|
|
|
status: vaultAiGuidanceStatus,
|
|
|
|
|
refresh: refreshVaultAiGuidance,
|
|
|
|
|
} = useVaultAiGuidanceStatus(
|
|
|
|
|
resolvedPath,
|
|
|
|
|
buildVaultAiGuidanceRefreshKey(vault.entries),
|
|
|
|
|
)
|
2026-04-07 20:31:08 +02:00
|
|
|
const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath)
|
2026-04-10 13:52:39 +02:00
|
|
|
const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization)
|
|
|
|
|
const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization)
|
|
|
|
|
|
2026-04-19 18:46:10 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
selectionRef.current = effectiveSelection
|
|
|
|
|
}, [effectiveSelection])
|
|
|
|
|
|
2026-04-10 13:52:39 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (effectiveSelection !== selection) {
|
2026-04-19 18:46:10 +02:00
|
|
|
if (effectiveSelection.kind !== 'entity') {
|
|
|
|
|
neighborhoodHistoryRef.current = []
|
|
|
|
|
}
|
2026-04-10 13:52:39 +02:00
|
|
|
setSelection(effectiveSelection)
|
|
|
|
|
setNoteListFilter('open')
|
|
|
|
|
}
|
|
|
|
|
}, [effectiveSelection, selection])
|
|
|
|
|
|
2026-04-19 18:46:10 +02:00
|
|
|
const handleNeighborhoodHistoryBack = useCallback(() => {
|
|
|
|
|
const { previousSelection, nextHistory } = popNeighborhoodHistory(neighborhoodHistoryRef.current)
|
|
|
|
|
if (!previousSelection) return false
|
|
|
|
|
|
|
|
|
|
neighborhoodHistoryRef.current = nextHistory
|
|
|
|
|
handleSetSelection(previousSelection, { preserveNeighborhoodHistory: true })
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
focusNoteListContainer(document)
|
|
|
|
|
})
|
|
|
|
|
return true
|
|
|
|
|
}, [handleSetSelection])
|
|
|
|
|
|
|
|
|
|
const shouldBlockNeighborhoodEscape = (
|
|
|
|
|
dialogs.showCreateTypeDialog
|
|
|
|
|
|| dialogs.showQuickOpen
|
|
|
|
|
|| dialogs.showCommandPalette
|
|
|
|
|
|| dialogs.showAIChat
|
|
|
|
|
|| dialogs.showSettings
|
|
|
|
|
|| dialogs.showCloneVault
|
|
|
|
|
|| dialogs.showSearch
|
|
|
|
|
|| dialogs.showConflictResolver
|
|
|
|
|
|| dialogs.showCreateViewDialog
|
|
|
|
|
|| showFeedback
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
|
|
|
|
if (!shouldProcessNeighborhoodEscape(event, selectionRef.current, shouldBlockNeighborhoodEscape)) return
|
|
|
|
|
|
|
|
|
|
const activeElement = document.activeElement
|
|
|
|
|
if (isEditorEscapeTarget(activeElement)) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
activeElement.blur()
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
focusNoteListContainer(document)
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isEditableElement(activeElement)) return
|
|
|
|
|
|
|
|
|
|
if (handleNeighborhoodHistoryBack()) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.addEventListener('keydown', handleWindowKeyDown)
|
|
|
|
|
return () => window.removeEventListener('keydown', handleWindowKeyDown)
|
|
|
|
|
}, [handleNeighborhoodHistoryBack, shouldBlockNeighborhoodEscape])
|
|
|
|
|
|
2026-04-10 13:52:39 +02:00
|
|
|
const handleSaveExplicitOrganization = useCallback((enabled: boolean) => {
|
|
|
|
|
updateConfig('inbox', {
|
|
|
|
|
noteListProperties: vaultConfig.inbox?.noteListProperties ?? null,
|
|
|
|
|
explicitOrganization: enabled,
|
|
|
|
|
})
|
|
|
|
|
}, [updateConfig, vaultConfig.inbox?.noteListProperties])
|
2026-03-25 16:10:39 +01:00
|
|
|
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
|
2026-04-13 19:37:59 +02:00
|
|
|
const aiAgentPreferences = useAiAgentPreferences({
|
|
|
|
|
settings,
|
|
|
|
|
saveSettings,
|
|
|
|
|
aiAgentsStatus,
|
|
|
|
|
onToast: setToastMessage,
|
|
|
|
|
})
|
2026-03-25 16:20:09 +01:00
|
|
|
useTelemetry(settings, settingsLoaded)
|
feat: implement PostHog event tracking plan
Add trackEvent() calls for all user actions in the tracking plan:
- Vault: vault_opened (with has_git, note_count), vault_switched
- Notes: note_created (with has_type, creation_path), note_deleted,
note_trashed, note_archived, note_favorited, note_unfavorited
- Git: commit_made, sync_triggered
- Search: search_used
- Editor: raw_mode_toggled, wikilink_inserted
- Types & Views: type_created, view_created
- Telemetry: telemetry_opted_in, telemetry_opted_out
All events gated by PostHog initialization (only fires when opted in).
No PII in any event properties — counts, booleans, and enums only.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 20:42:43 +02:00
|
|
|
|
|
|
|
|
const vaultOpenedRef = useRef('')
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (vault.entries.length > 0 && gitRepoState !== 'checking' && resolvedPath !== vaultOpenedRef.current) {
|
|
|
|
|
vaultOpenedRef.current = resolvedPath
|
|
|
|
|
trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length })
|
|
|
|
|
}
|
|
|
|
|
}, [vault.entries.length, gitRepoState, resolvedPath])
|
2026-03-04 13:11:58 +01:00
|
|
|
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
2026-04-12 19:38:25 +02:00
|
|
|
const gitRemoteStatus = useGitRemoteStatus(resolvedPath)
|
feat: auto-pull vault changes from Git (background sync + conflict handling) (#79)
* feat: add auto-pull vault sync with conflict handling
- Add git_pull, has_remote, get_conflict_files to Rust backend
- Add GitPullResult type and auto_pull_interval_minutes to Settings
- Create useAutoSync hook (pull on launch, focus, periodic interval)
- Update StatusBar with real sync status indicator
- Add pull interval setting to SettingsPanel
- Add mock handler for git_pull
- Update existing tests for new Settings field
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: add tests for git pull, settings, and useAutoSync hook
- Add Rust tests: has_remote, git_pull (no_remote, up_to_date, updated),
get_conflict_files, parse_updated_files, GitPullResult serialization
- Add frontend tests: useAutoSync (mount pull, focus pull, conflict,
error, manual trigger, concurrent prevention, no_remote)
- Fix existing settings tests for new auto_pull_interval_minutes field
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* design: add auto-pull-vault wireframes
Frames showing: sync idle, syncing, conflict indicator,
settings sync section, and conflict toast notification.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add auto_pull_interval_minutes to all Settings literals
Fix build error and test fixtures missing the new field.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: cargo fmt
* ci: re-trigger CI after flaky test
* ci: retrigger after disk space cleanup
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 01:55:16 +01:00
|
|
|
|
2026-03-31 11:58:32 +02:00
|
|
|
// Detect external file renames on window focus
|
|
|
|
|
const [detectedRenames, setDetectedRenames] = useState<DetectedRename[]>([])
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isTauri() || !resolvedPath) return
|
|
|
|
|
const handleFocus = () => {
|
|
|
|
|
invoke<DetectedRename[]>('detect_renames', { vaultPath: resolvedPath })
|
|
|
|
|
.then(renames => { if (renames.length > 0) setDetectedRenames(renames) })
|
|
|
|
|
.catch(() => {}) // ignore errors (e.g., no git)
|
|
|
|
|
}
|
|
|
|
|
window.addEventListener('focus', handleFocus)
|
|
|
|
|
return () => window.removeEventListener('focus', handleFocus)
|
|
|
|
|
}, [resolvedPath])
|
|
|
|
|
|
|
|
|
|
const handleUpdateWikilinks = useCallback(async () => {
|
|
|
|
|
if (!isTauri()) return
|
|
|
|
|
try {
|
|
|
|
|
const count = await invoke<number>('update_wikilinks_for_renames', { vaultPath: resolvedPath, renames: detectedRenames })
|
|
|
|
|
setDetectedRenames([])
|
|
|
|
|
vault.reloadVault()
|
|
|
|
|
setToastMessage(`Updated wikilinks in ${count} file${count !== 1 ? 's' : ''}`)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setToastMessage(`Failed to update wikilinks: ${err}`)
|
|
|
|
|
}
|
|
|
|
|
}, [resolvedPath, detectedRenames, vault, setToastMessage])
|
|
|
|
|
|
|
|
|
|
const handleDismissRenames = useCallback(() => setDetectedRenames([]), [])
|
|
|
|
|
|
2026-03-03 02:27:42 +01:00
|
|
|
const conflictResolver = useConflictResolver({
|
|
|
|
|
vaultPath: resolvedPath,
|
|
|
|
|
onResolved: () => {
|
|
|
|
|
dialogs.closeConflictResolver()
|
|
|
|
|
autoSync.resumePull()
|
|
|
|
|
vault.reloadVault()
|
|
|
|
|
autoSync.triggerSync()
|
|
|
|
|
},
|
|
|
|
|
onToast: (msg) => setToastMessage(msg),
|
2026-03-29 10:35:13 +02:00
|
|
|
onOpenFile: (relativePath) => conflictFlow.openConflictFileRef.current(relativePath),
|
2026-03-03 02:27:42 +01:00
|
|
|
})
|
2026-04-18 09:19:29 +02:00
|
|
|
const flushPendingRawContentRef = useRef<((path: string) => void) | null>(null)
|
2026-04-21 18:50:50 +02:00
|
|
|
const flushEditorStateBeforeAction = async (path: string) => {
|
|
|
|
|
flushPendingRawContentRef.current?.(path)
|
|
|
|
|
await appSave.flushBeforeAction(path)
|
|
|
|
|
}
|
2026-03-03 02:27:42 +01:00
|
|
|
|
2026-04-14 16:49:18 +02:00
|
|
|
const notes = useNoteActions({
|
|
|
|
|
addEntry: vault.addEntry,
|
|
|
|
|
removeEntry: vault.removeEntry,
|
|
|
|
|
entries: vault.entries,
|
2026-04-21 18:50:50 +02:00
|
|
|
flushBeforeNoteSwitch: flushEditorStateBeforeAction,
|
|
|
|
|
flushBeforeFrontmatterChange: flushEditorStateBeforeAction,
|
|
|
|
|
flushBeforePathRename: flushEditorStateBeforeAction,
|
2026-04-14 16:49:18 +02:00
|
|
|
reloadVault: vault.reloadVault,
|
|
|
|
|
setToastMessage,
|
|
|
|
|
updateEntry: vault.updateEntry,
|
|
|
|
|
vaultPath: resolvedPath,
|
|
|
|
|
addPendingSave: vault.addPendingSave,
|
|
|
|
|
removePendingSave: vault.removePendingSave,
|
|
|
|
|
trackUnsaved: vault.trackUnsaved,
|
|
|
|
|
clearUnsaved: vault.clearUnsaved,
|
|
|
|
|
unsavedPaths: vault.unsavedPaths,
|
|
|
|
|
markContentPending: (path, content) => appSave.contentChangeRef.current(path, content),
|
|
|
|
|
onNewNotePersisted: vault.loadModifiedFiles,
|
|
|
|
|
replaceEntry: vault.replaceEntry,
|
|
|
|
|
onFrontmatterPersisted: vault.loadModifiedFiles,
|
|
|
|
|
onPathRenamed: (oldPath, newPath) => appSave.trackRenamedPath(oldPath, newPath),
|
|
|
|
|
})
|
2026-04-19 21:27:32 +02:00
|
|
|
const {
|
|
|
|
|
handleSelectNote,
|
|
|
|
|
handleReplaceActiveTab,
|
2026-04-20 14:43:52 +02:00
|
|
|
closeAllTabs,
|
2026-04-19 21:27:32 +02:00
|
|
|
openTabWithContent,
|
|
|
|
|
} = notes
|
2026-04-20 16:04:11 +02:00
|
|
|
const handlePulledVaultUpdate = useCallback(async (updatedFiles: string[]) => {
|
|
|
|
|
await refreshPulledVaultState({
|
2026-04-20 14:43:52 +02:00
|
|
|
activeTabPath: notes.activeTabPath,
|
|
|
|
|
closeAllTabs,
|
|
|
|
|
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
|
|
|
|
|
reloadFolders: vault.reloadFolders,
|
|
|
|
|
reloadVault: vault.reloadVault,
|
|
|
|
|
reloadViews: vault.reloadViews,
|
|
|
|
|
replaceActiveTab: handleReplaceActiveTab,
|
|
|
|
|
updatedFiles,
|
|
|
|
|
vaultPath: resolvedPath,
|
2026-04-20 16:04:11 +02:00
|
|
|
})
|
|
|
|
|
}, [
|
2026-04-20 14:43:52 +02:00
|
|
|
closeAllTabs,
|
|
|
|
|
handleReplaceActiveTab,
|
|
|
|
|
notes.activeTabPath,
|
|
|
|
|
resolvedPath,
|
|
|
|
|
vault.reloadFolders,
|
|
|
|
|
vault.reloadVault,
|
|
|
|
|
vault.reloadViews,
|
|
|
|
|
vault.unsavedPaths,
|
|
|
|
|
])
|
|
|
|
|
const autoSync = useAutoSync({
|
|
|
|
|
vaultPath: resolvedPath,
|
|
|
|
|
intervalMinutes: settings.auto_pull_interval_minutes,
|
|
|
|
|
onVaultUpdated: handlePulledVaultUpdate,
|
|
|
|
|
onConflict: (files) => {
|
|
|
|
|
const names = files.map((f) => f.split('/').pop()).join(', ')
|
|
|
|
|
setToastMessage(`Conflict in ${names} — click to resolve`)
|
|
|
|
|
},
|
|
|
|
|
onToast: (msg) => setToastMessage(msg),
|
|
|
|
|
})
|
2026-04-15 17:57:38 +02:00
|
|
|
const pulseCommitDiffRequestIdRef = useRef(0)
|
|
|
|
|
const [pulseCommitDiffRequest, setPulseCommitDiffRequest] = useState<CommitDiffRequest | null>(null)
|
2026-02-15 12:56:28 +01:00
|
|
|
|
2026-03-30 18:30:38 +02:00
|
|
|
// Note window: auto-open the note from URL params once vault entries load
|
|
|
|
|
const noteWindowOpenedRef = useRef(false)
|
2026-04-14 18:17:45 +02:00
|
|
|
const noteWindowMissingPathRef = useRef<string | null>(null)
|
2026-03-30 18:30:38 +02:00
|
|
|
useEffect(() => {
|
2026-04-14 18:17:45 +02:00
|
|
|
if (!noteWindowParams || noteWindowOpenedRef.current) return
|
|
|
|
|
let cancelled = false
|
|
|
|
|
|
|
|
|
|
void resolveNoteWindowEntry(noteWindowParams, vault.entries).then((entry) => {
|
|
|
|
|
if (cancelled || noteWindowOpenedRef.current) return
|
|
|
|
|
if (entry) {
|
|
|
|
|
noteWindowOpenedRef.current = true
|
|
|
|
|
noteWindowMissingPathRef.current = null
|
|
|
|
|
void handleSelectNote(entry)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (noteWindowMissingPathRef.current === noteWindowParams.notePath) return
|
|
|
|
|
noteWindowMissingPathRef.current = noteWindowParams.notePath
|
|
|
|
|
setToastMessage(`Could not open "${noteWindowParams.noteTitle}" in this window`)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true
|
2026-03-30 18:30:38 +02:00
|
|
|
}
|
2026-04-14 18:17:45 +02:00
|
|
|
}, [handleSelectNote, noteWindowParams, setToastMessage, vault.entries])
|
2026-03-30 18:30:38 +02:00
|
|
|
|
|
|
|
|
// Note window: update window title when active note changes
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!noteWindowParams) return
|
|
|
|
|
const activeEntry = notes.tabs.find(t => t.entry.path === notes.activeTabPath)?.entry
|
|
|
|
|
const title = activeEntry?.title ?? noteWindowParams.noteTitle
|
|
|
|
|
if (!isTauri()) { document.title = title; return }
|
|
|
|
|
import('@tauri-apps/api/window').then(({ getCurrentWindow }) => {
|
|
|
|
|
getCurrentWindow().setTitle(title)
|
|
|
|
|
}).catch(() => {})
|
|
|
|
|
}, [noteWindowParams, notes.tabs, notes.activeTabPath])
|
|
|
|
|
|
refactor: remove tab bar — single note open at a time
Replace the multi-tab model with single-note navigation. One note is
open at a time; clicking any note (sidebar, wikilink, relationship)
replaces the current note in the editor. Back/Forward history still
works via Cmd+[/].
Removed: TabBar component, useClosedTabHistory, tab reorder/close/
reopen logic, Cmd+W/Cmd+Shift+T shortcuts, Close Tab and Reopen
Closed Tab menu items, tabLayout utility, and all related tests.
Simplified: useTabManagement (single-note), useAppNavigation (no tab
lookup), useKeyboardNavigation (note nav only), useNoteCreation (no
close-tab cleanup), useDeleteActions (deselect instead of close tab),
Editor (no TabBar render), Rust menu (removed 2 menu items).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:24:49 +01:00
|
|
|
// Keep note entry in sync with vault entries so banners (trash/archive)
|
2026-03-03 19:33:12 +01:00
|
|
|
// and read-only state react immediately without reopening the note.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
notes.setTabs(prev => {
|
|
|
|
|
let changed = false
|
|
|
|
|
const next = prev.map(tab => {
|
|
|
|
|
const fresh = vault.entries.find(e => e.path === tab.entry.path)
|
|
|
|
|
if (fresh && fresh !== tab.entry) {
|
|
|
|
|
changed = true
|
|
|
|
|
return { ...tab, entry: fresh }
|
|
|
|
|
}
|
|
|
|
|
return tab
|
|
|
|
|
})
|
|
|
|
|
return changed ? next : prev
|
|
|
|
|
})
|
|
|
|
|
}, [vault.entries]) // eslint-disable-line react-hooks/exhaustive-deps -- notes.setTabs is stable (useState setter)
|
|
|
|
|
|
2026-03-12 07:24:46 +01:00
|
|
|
const { handleGoBack, handleGoForward, canGoBack, canGoForward, entriesByPath } = useAppNavigation({
|
|
|
|
|
entries: vault.entries,
|
|
|
|
|
activeTabPath: notes.activeTabPath,
|
|
|
|
|
onSelectNote: notes.handleSelectNote,
|
|
|
|
|
})
|
2026-03-06 21:28:07 +01:00
|
|
|
|
2026-04-15 17:57:38 +02:00
|
|
|
const queuePulseCommitDiff = useCallback((path: string, commitHash: string) => {
|
|
|
|
|
pulseCommitDiffRequestIdRef.current += 1
|
|
|
|
|
setPulseCommitDiffRequest({
|
|
|
|
|
requestId: pulseCommitDiffRequestIdRef.current,
|
|
|
|
|
path,
|
|
|
|
|
commitHash,
|
|
|
|
|
})
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const handlePulseCommitDiffHandled = useCallback((requestId: number) => {
|
|
|
|
|
setPulseCommitDiffRequest((current) =>
|
|
|
|
|
current?.requestId === requestId ? null : current,
|
|
|
|
|
)
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const handlePulseOpenNote = useCallback((relativePath: string, commitHash?: string) => {
|
|
|
|
|
const fullPath = `${resolvedPath}/${relativePath}`
|
|
|
|
|
const entry = entriesByPath.get(fullPath) ?? entriesByPath.get(relativePath)
|
|
|
|
|
|
|
|
|
|
if (commitHash) {
|
|
|
|
|
const targetPath = entry?.path ?? fullPath
|
|
|
|
|
queuePulseCommitDiff(targetPath, commitHash)
|
|
|
|
|
if (entry) {
|
|
|
|
|
void handleSelectNote(entry)
|
|
|
|
|
} else {
|
|
|
|
|
openTabWithContent(createPulseDeletedNoteEntry(fullPath, relativePath), 'Content not available')
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (entry) {
|
|
|
|
|
void handleSelectNote(entry)
|
|
|
|
|
}
|
|
|
|
|
}, [entriesByPath, resolvedPath, queuePulseCommitDiff, handleSelectNote, openTabWithContent])
|
|
|
|
|
|
2026-04-19 21:27:32 +02:00
|
|
|
const handleOpenFavorite = useCallback(async (entry: VaultEntry) => {
|
|
|
|
|
await handleReplaceActiveTab(entry)
|
|
|
|
|
handleEnterNeighborhood(entry)
|
|
|
|
|
}, [handleEnterNeighborhood, handleReplaceActiveTab])
|
|
|
|
|
|
2026-03-29 10:35:13 +02:00
|
|
|
const vaultBridge = useVaultBridge({
|
2026-04-20 22:04:38 +02:00
|
|
|
entriesByPath,
|
|
|
|
|
resolvedPath,
|
2026-03-29 10:35:13 +02:00
|
|
|
reloadVault: vault.reloadVault,
|
2026-04-20 22:04:38 +02:00
|
|
|
reloadFolders: vault.reloadFolders,
|
|
|
|
|
reloadViews: vault.reloadViews,
|
|
|
|
|
closeAllTabs,
|
|
|
|
|
replaceActiveTab: handleReplaceActiveTab,
|
|
|
|
|
hasUnsavedChanges: (path) => vault.unsavedPaths.has(path),
|
2026-03-29 10:35:13 +02:00
|
|
|
onSelectNote: notes.handleSelectNote,
|
|
|
|
|
activeTabPath: notes.activeTabPath,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const conflictFlow = useConflictFlow({
|
|
|
|
|
resolvedPath, entries: vault.entries,
|
|
|
|
|
conflictFiles: autoSync.conflictFiles,
|
|
|
|
|
pausePull: autoSync.pausePull, resumePull: autoSync.resumePull,
|
|
|
|
|
triggerSync: autoSync.triggerSync, reloadVault: vault.reloadVault,
|
|
|
|
|
initConflictFiles: conflictResolver.initFiles,
|
|
|
|
|
openConflictResolver: dialogs.openConflictResolver,
|
|
|
|
|
closeConflictResolver: dialogs.closeConflictResolver,
|
|
|
|
|
onSelectNote: notes.handleSelectNote,
|
|
|
|
|
activeTabPath: notes.activeTabPath,
|
|
|
|
|
setToastMessage,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const appSave = useAppSave({
|
2026-04-11 15:22:34 +02:00
|
|
|
updateEntry: vault.updateEntry, setTabs: notes.setTabs, handleSwitchTab: notes.handleSwitchTab, setToastMessage,
|
2026-04-21 15:09:27 +02:00
|
|
|
loadModifiedFiles: vault.loadModifiedFiles, reloadViews: async () => { await vault.reloadViews() },
|
2026-03-29 10:35:13 +02:00
|
|
|
clearUnsaved: vault.clearUnsaved, unsavedPaths: vault.unsavedPaths,
|
|
|
|
|
tabs: notes.tabs, activeTabPath: notes.activeTabPath,
|
2026-04-14 15:40:46 +02:00
|
|
|
handleRenameNote: notes.handleRenameNote, handleRenameFilename: notes.handleRenameFilename,
|
2026-03-29 10:35:13 +02:00
|
|
|
replaceEntry: vault.replaceEntry, resolvedPath,
|
2026-04-16 12:18:11 +02:00
|
|
|
initialH1AutoRenameEnabled: settings.initial_h1_auto_rename_enabled !== false,
|
2026-03-29 10:35:13 +02:00
|
|
|
})
|
2026-03-05 12:12:12 +01:00
|
|
|
|
2026-03-04 10:05:43 +01:00
|
|
|
const aiActivity = useAiActivity({
|
2026-03-29 10:35:13 +02:00
|
|
|
onOpenNote: vaultBridge.openNoteByPath,
|
|
|
|
|
onOpenTab: vaultBridge.openNoteByPath,
|
2026-03-04 10:05:43 +01:00
|
|
|
onSetFilter: (filterType) => {
|
2026-03-18 03:43:23 +01:00
|
|
|
handleSetSelection({ kind: 'sectionGroup', type: filterType })
|
2026-03-04 10:05:43 +01:00
|
|
|
},
|
|
|
|
|
onVaultChanged: () => { vault.reloadVault() },
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-31 11:07:39 +02:00
|
|
|
const handleInitializeProperties = useCallback(async (path: string) => {
|
2026-04-19 18:18:35 +02:00
|
|
|
await initializeNoteProperties(notes.handleUpdateFrontmatter, path)
|
2026-03-31 11:07:39 +02:00
|
|
|
}, [notes])
|
|
|
|
|
|
2026-03-17 22:42:55 +01:00
|
|
|
const handleRemoveNoteIcon = useCallback(async (path: string) => {
|
|
|
|
|
await notes.handleDeleteProperty(path, 'icon')
|
|
|
|
|
}, [notes])
|
|
|
|
|
|
|
|
|
|
const handleSetNoteIconCommand = useCallback(() => {
|
2026-04-07 21:09:06 +02:00
|
|
|
setInspectorCollapsed(false)
|
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
window.requestAnimationFrame(() => {
|
|
|
|
|
focusNoteIconPropertyEditor()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
}, [setInspectorCollapsed])
|
2026-03-17 22:42:55 +01:00
|
|
|
|
2026-04-16 02:18:43 +02:00
|
|
|
const handleCustomizeNoteListColumns = useCallback(() => {
|
2026-04-18 20:31:25 +02:00
|
|
|
if (effectiveSelection.kind === 'view') {
|
|
|
|
|
openNoteListPropertiesPicker('view')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 02:18:43 +02:00
|
|
|
if (effectiveSelection.kind !== 'filter') return
|
|
|
|
|
if (effectiveSelection.filter === 'all') {
|
|
|
|
|
openNoteListPropertiesPicker('all')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (effectiveSelection.filter === 'inbox') {
|
|
|
|
|
openNoteListPropertiesPicker('inbox')
|
|
|
|
|
}
|
|
|
|
|
}, [effectiveSelection])
|
|
|
|
|
|
|
|
|
|
const handleUpdateAllNotesNoteListProperties = useCallback((value: string[] | null) => {
|
|
|
|
|
updateConfig('allNotes', {
|
|
|
|
|
...(vaultConfig.allNotes ?? { noteListProperties: null }),
|
|
|
|
|
noteListProperties: value && value.length > 0 ? value : null,
|
|
|
|
|
})
|
|
|
|
|
}, [updateConfig, vaultConfig.allNotes])
|
2026-04-07 20:31:08 +02:00
|
|
|
|
|
|
|
|
const handleUpdateInboxNoteListProperties = useCallback((value: string[] | null) => {
|
|
|
|
|
updateConfig('inbox', {
|
|
|
|
|
...(vaultConfig.inbox ?? { noteListProperties: null }),
|
|
|
|
|
noteListProperties: value && value.length > 0 ? value : null,
|
|
|
|
|
})
|
|
|
|
|
}, [updateConfig, vaultConfig.inbox])
|
|
|
|
|
|
2026-04-01 15:29:24 +02:00
|
|
|
const handleCreateFolder = useCallback(async (name: string) => {
|
|
|
|
|
try {
|
|
|
|
|
if (isTauri()) {
|
|
|
|
|
await invoke('create_vault_folder', { vaultPath: resolvedPath, folderName: name })
|
|
|
|
|
} else {
|
|
|
|
|
await mockInvoke('create_vault_folder', { vaultPath: resolvedPath, folderName: name })
|
|
|
|
|
}
|
2026-04-02 13:47:50 +02:00
|
|
|
await vault.reloadFolders()
|
2026-04-01 15:29:24 +02:00
|
|
|
setToastMessage(`Created folder "${name}"`)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setToastMessage(`Failed to create folder: ${e}`)
|
|
|
|
|
}
|
|
|
|
|
}, [resolvedPath, vault, setToastMessage])
|
|
|
|
|
|
2026-03-17 22:42:55 +01:00
|
|
|
const handleRemoveNoteIconCommand = useCallback(() => {
|
|
|
|
|
if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath)
|
|
|
|
|
}, [notes.activeTabPath, handleRemoveNoteIcon])
|
|
|
|
|
|
2026-03-19 08:47:25 +01:00
|
|
|
const handleOpenInNewWindow = useCallback(() => {
|
|
|
|
|
const activeTab = notes.tabs.find(t => t.entry.path === notes.activeTabPath)
|
|
|
|
|
if (activeTab) openNoteInNewWindow(activeTab.entry.path, resolvedPath, activeTab.entry.title)
|
2026-03-29 10:35:13 +02:00
|
|
|
}, [notes.tabs, notes.activeTabPath, resolvedPath])
|
2026-03-19 08:47:25 +01:00
|
|
|
|
2026-03-29 10:35:13 +02:00
|
|
|
const handleOpenEntryInNewWindow = useCallback((entry: { path: string; title: string }) => {
|
2026-03-19 08:47:25 +01:00
|
|
|
openNoteInNewWindow(entry.path, resolvedPath, entry.title)
|
|
|
|
|
}, [resolvedPath])
|
|
|
|
|
|
2026-04-04 18:16:52 +02:00
|
|
|
const handleDiscardFile = useCallback(async (relativePath: string) => {
|
2026-04-07 20:09:04 +02:00
|
|
|
const targetFile = vault.modifiedFiles.find((file) => file.relativePath === relativePath)
|
|
|
|
|
const activePathBefore = notes.activeTabPath
|
2026-04-04 18:16:52 +02:00
|
|
|
try {
|
|
|
|
|
if (isTauri()) {
|
|
|
|
|
await invoke('git_discard_file', { vaultPath: resolvedPath, relativePath })
|
|
|
|
|
} else {
|
|
|
|
|
await mockInvoke('git_discard_file', { vaultPath: resolvedPath, relativePath })
|
|
|
|
|
}
|
2026-04-07 20:09:04 +02:00
|
|
|
const reloadedEntries = await vault.reloadVault()
|
|
|
|
|
const affectedActiveTab = !!activePathBefore
|
|
|
|
|
&& (activePathBefore === targetFile?.path || activePathBefore.endsWith('/' + relativePath))
|
|
|
|
|
if (!affectedActiveTab) return
|
|
|
|
|
const refreshedEntry = reloadedEntries.find((entry) =>
|
|
|
|
|
entry.path === targetFile?.path || entry.path.endsWith('/' + relativePath),
|
|
|
|
|
)
|
|
|
|
|
if (refreshedEntry) {
|
|
|
|
|
await notes.handleReplaceActiveTab(refreshedEntry)
|
|
|
|
|
} else {
|
|
|
|
|
notes.closeAllTabs()
|
|
|
|
|
}
|
2026-04-04 18:16:52 +02:00
|
|
|
} catch (err) {
|
|
|
|
|
setToastMessage(typeof err === 'string' ? err : 'Failed to discard changes')
|
|
|
|
|
}
|
2026-04-07 20:09:04 +02:00
|
|
|
}, [resolvedPath, vault, notes, setToastMessage])
|
|
|
|
|
|
|
|
|
|
const handleOpenDeletedNote = useCallback(async (entry: DeletedNoteEntry) => {
|
|
|
|
|
let previewContent = 'Content not available (untracked)'
|
|
|
|
|
let hasDiff = false
|
|
|
|
|
try {
|
|
|
|
|
const diff = await vault.loadDiff(entry.path)
|
|
|
|
|
hasDiff = diff.length > 0
|
|
|
|
|
previewContent = extractDeletedContentFromDiff(diff) ?? previewContent
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.warn('Failed to load deleted note preview:', err)
|
|
|
|
|
}
|
|
|
|
|
notes.openTabWithContent(entry, previewContent)
|
|
|
|
|
if (hasDiff) {
|
|
|
|
|
setTimeout(() => diffToggleRef.current(), 50)
|
|
|
|
|
} else {
|
|
|
|
|
setToastMessage('Content not available (untracked)')
|
|
|
|
|
}
|
|
|
|
|
}, [vault, notes, setToastMessage])
|
2026-04-04 18:16:52 +02:00
|
|
|
|
2026-04-12 19:38:25 +02:00
|
|
|
const commitFlow = useCommitFlow({
|
|
|
|
|
savePending: appSave.savePending,
|
|
|
|
|
loadModifiedFiles: vault.loadModifiedFiles,
|
|
|
|
|
resolveRemoteStatus: gitRemoteStatus.refreshRemoteStatus,
|
|
|
|
|
setToastMessage,
|
|
|
|
|
onPushRejected: autoSync.handlePushRejected,
|
|
|
|
|
vaultPath: resolvedPath,
|
|
|
|
|
})
|
2026-03-30 16:30:54 +02:00
|
|
|
const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles])
|
2026-04-16 23:38:30 +02:00
|
|
|
const isGitVault = !vault.modifiedFilesError
|
|
|
|
|
const modifiedFilesSignature = useMemo(
|
|
|
|
|
() => vault.modifiedFiles.map((file) => `${file.relativePath}:${file.status}`).sort().join('|'),
|
|
|
|
|
[vault.modifiedFiles],
|
|
|
|
|
)
|
|
|
|
|
const autoGit = useAutoGit({
|
|
|
|
|
enabled: settings.autogit_enabled === true,
|
|
|
|
|
idleThresholdSeconds: settings.autogit_idle_threshold_seconds ?? 90,
|
|
|
|
|
inactiveThresholdSeconds: settings.autogit_inactive_threshold_seconds ?? 30,
|
|
|
|
|
isGitVault,
|
|
|
|
|
hasPendingChanges: vault.modifiedFiles.length > 0
|
|
|
|
|
|| ((autoSync.remoteStatus?.hasRemote ?? false) && (autoSync.remoteStatus?.ahead ?? 0) > 0),
|
|
|
|
|
hasUnsavedChanges: vault.unsavedPaths.size > 0,
|
|
|
|
|
onCheckpoint: () => commitFlow.runAutomaticCheckpoint(),
|
|
|
|
|
})
|
|
|
|
|
const recordAutoGitActivity = autoGit.recordActivity
|
2026-04-18 09:58:49 +02:00
|
|
|
const openCommitDialog = commitFlow.openCommitDialog
|
2026-04-16 23:38:30 +02:00
|
|
|
const runAutomaticCheckpoint = commitFlow.runAutomaticCheckpoint
|
|
|
|
|
const handleAppContentChange = appSave.handleContentChange
|
|
|
|
|
const handleAppSave = appSave.handleSave
|
|
|
|
|
const loadModifiedFiles = vault.loadModifiedFiles
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (modifiedFilesSignature.length === 0) return
|
|
|
|
|
recordAutoGitActivity()
|
|
|
|
|
}, [modifiedFilesSignature, recordAutoGitActivity])
|
|
|
|
|
|
2026-04-18 09:58:49 +02:00
|
|
|
const handleCommitPush = useCallback(() => {
|
|
|
|
|
triggerCommitEntryAction({
|
|
|
|
|
autoGitEnabled: settings.autogit_enabled === true,
|
|
|
|
|
openCommitDialog,
|
|
|
|
|
runAutomaticCheckpoint,
|
|
|
|
|
})
|
|
|
|
|
}, [openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled])
|
2026-04-16 23:38:30 +02:00
|
|
|
|
|
|
|
|
const handleTrackedContentChange = useCallback((path: string, content: string) => {
|
|
|
|
|
recordAutoGitActivity()
|
|
|
|
|
handleAppContentChange(path, content)
|
|
|
|
|
}, [handleAppContentChange, recordAutoGitActivity])
|
|
|
|
|
|
|
|
|
|
const handleTrackedSave = useCallback(async (...args: Parameters<typeof handleAppSave>) => {
|
|
|
|
|
const result = await handleAppSave(...args)
|
|
|
|
|
recordAutoGitActivity()
|
|
|
|
|
return result
|
|
|
|
|
}, [handleAppSave, recordAutoGitActivity])
|
|
|
|
|
|
|
|
|
|
const seedAutoGitSavedChange = useCallback(async () => {
|
|
|
|
|
if (isTauri()) {
|
|
|
|
|
throw new Error('seedAutoGitSavedChange is only available in browser smoke tests')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const activePath = notes.activeTabPath
|
|
|
|
|
const activeTab = activePath
|
|
|
|
|
? notes.tabs.find((tab) => tab.entry.path === activePath)
|
|
|
|
|
: null
|
|
|
|
|
|
|
|
|
|
if (!activePath || !activeTab) {
|
|
|
|
|
throw new Error('No active note is available for the AutoGit test bridge')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const saveNoteContent = window.__mockHandlers?.save_note_content
|
|
|
|
|
if (typeof saveNoteContent === 'function') {
|
|
|
|
|
await Promise.resolve(saveNoteContent({ path: activePath, content: activeTab.content }))
|
|
|
|
|
} else {
|
|
|
|
|
await mockInvoke('save_note_content', { path: activePath, content: activeTab.content })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await loadModifiedFiles()
|
|
|
|
|
recordAutoGitActivity()
|
|
|
|
|
}, [loadModifiedFiles, notes.activeTabPath, notes.tabs, recordAutoGitActivity])
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
window.__laputaTest = {
|
|
|
|
|
...window.__laputaTest,
|
|
|
|
|
seedAutoGitSavedChange,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
if (window.__laputaTest?.seedAutoGitSavedChange === seedAutoGitSavedChange) {
|
|
|
|
|
delete window.__laputaTest.seedAutoGitSavedChange
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [seedAutoGitSavedChange])
|
2026-02-23 20:34:12 +01:00
|
|
|
|
2026-02-22 10:48:13 +01:00
|
|
|
const entryActions = useEntryActions({
|
2026-02-25 13:31:19 +01:00
|
|
|
entries: vault.entries, updateEntry: vault.updateEntry,
|
2026-02-22 10:48:13 +01:00
|
|
|
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
|
2026-02-25 13:31:19 +01:00
|
|
|
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
|
2026-03-02 11:22:03 +01:00
|
|
|
createTypeEntry: notes.createTypeEntrySilent,
|
2026-03-29 10:35:13 +02:00
|
|
|
onBeforeAction: appSave.flushBeforeAction,
|
2026-02-22 10:48:13 +01:00
|
|
|
})
|
|
|
|
|
|
2026-03-12 06:28:19 +01:00
|
|
|
const deleteActions = useDeleteActions({
|
refactor: remove tab bar — single note open at a time
Replace the multi-tab model with single-note navigation. One note is
open at a time; clicking any note (sidebar, wikilink, relationship)
replaces the current note in the editor. Back/Forward history still
works via Cmd+[/].
Removed: TabBar component, useClosedTabHistory, tab reorder/close/
reopen logic, Cmd+W/Cmd+Shift+T shortcuts, Close Tab and Reopen
Closed Tab menu items, tabLayout utility, and all related tests.
Simplified: useTabManagement (single-note), useAppNavigation (no tab
lookup), useKeyboardNavigation (note nav only), useNoteCreation (no
close-tab cleanup), useDeleteActions (deselect instead of close tab),
Editor (no TabBar render), Rust menu (removed 2 menu items).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:24:49 +01:00
|
|
|
onDeselectNote: (path: string) => { if (notes.activeTabPath === path) notes.closeAllTabs() },
|
2026-03-12 06:28:19 +01:00
|
|
|
removeEntry: vault.removeEntry,
|
2026-04-14 15:40:46 +02:00
|
|
|
removeEntries: vault.removeEntries,
|
2026-04-14 10:53:08 +02:00
|
|
|
refreshModifiedFiles: vault.loadModifiedFiles,
|
|
|
|
|
reloadVault: vault.reloadVault,
|
2026-03-12 06:28:19 +01:00
|
|
|
setToastMessage,
|
|
|
|
|
})
|
2026-03-12 00:06:33 +01:00
|
|
|
|
2026-04-20 00:09:52 +02:00
|
|
|
const shouldLoadGitHistory = !layout.inspectorCollapsed && !showAIChat
|
|
|
|
|
const gitHistory = useGitHistory(notes.activeTabPath, vault.loadGitHistory, shouldLoadGitHistory)
|
2026-02-20 23:49:30 +01:00
|
|
|
|
2026-02-21 09:41:46 +01:00
|
|
|
const handleCreateType = useCallback((name: string) => {
|
|
|
|
|
notes.handleCreateType(name)
|
|
|
|
|
setToastMessage(`Type "${name}" created`)
|
2026-04-17 00:44:14 +02:00
|
|
|
}, [notes, setToastMessage])
|
|
|
|
|
|
|
|
|
|
const handleCreateMissingType = useCallback(async (path: string, missingType: string, nextTypeName: string) => {
|
|
|
|
|
const trimmed = nextTypeName.trim()
|
|
|
|
|
if (!trimmed) return
|
|
|
|
|
|
|
|
|
|
const targetFilename = `${slugify(trimmed)}.md`
|
|
|
|
|
const exactType = vault.entries.find((entry) => entry.isA === 'Type' && entry.title === trimmed)
|
|
|
|
|
const slugMatch = vault.entries.find((entry) => entry.isA === 'Type' && slugify(entry.title) === slugify(trimmed))
|
|
|
|
|
const filenameCollision = vault.entries.find((entry) => entry.filename.toLowerCase() === targetFilename)
|
|
|
|
|
const resolvedTypeName = exactType?.title ?? slugMatch?.title ?? trimmed
|
|
|
|
|
|
|
|
|
|
if (filenameCollision && filenameCollision.isA !== 'Type') {
|
|
|
|
|
setToastMessage(`Cannot create type "${trimmed}" because ${targetFilename} already exists`)
|
|
|
|
|
throw new Error(`Type filename collision for ${targetFilename}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!exactType && !slugMatch) {
|
|
|
|
|
await notes.createTypeEntrySilent(trimmed)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await notes.handleUpdateFrontmatter(path, 'type', resolvedTypeName)
|
|
|
|
|
setToastMessage(
|
|
|
|
|
resolvedTypeName === missingType
|
|
|
|
|
? `Type "${resolvedTypeName}" created`
|
|
|
|
|
: `Type set to "${resolvedTypeName}"`,
|
|
|
|
|
)
|
|
|
|
|
}, [notes, setToastMessage, vault.entries])
|
2026-02-21 17:29:42 +01:00
|
|
|
|
2026-04-18 20:31:25 +02:00
|
|
|
const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => {
|
2026-04-04 02:50:49 +02:00
|
|
|
const editing = dialogs.editingView
|
|
|
|
|
const filename = editing
|
|
|
|
|
? editing.filename
|
|
|
|
|
: definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml'
|
2026-04-18 20:31:25 +02:00
|
|
|
const nextDefinition = editing ? { ...editing.definition, ...definition } : definition
|
2026-04-02 20:54:06 +02:00
|
|
|
const target = isTauri() ? invoke : mockInvoke
|
2026-04-18 20:31:25 +02:00
|
|
|
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition })
|
2026-04-04 02:50:49 +02:00
|
|
|
trackEvent(editing ? 'view_updated' : 'view_created')
|
2026-04-02 20:54:06 +02:00
|
|
|
await vault.reloadViews()
|
2026-04-04 19:38:39 +02:00
|
|
|
await vault.reloadVault()
|
2026-04-04 11:19:44 +02:00
|
|
|
vault.reloadFolders()
|
2026-04-18 20:31:25 +02:00
|
|
|
setToastMessage(editing ? `View "${nextDefinition.name}" updated` : `View "${nextDefinition.name}" created`)
|
2026-04-02 20:54:06 +02:00
|
|
|
handleSetSelection({ kind: 'view', filename })
|
2026-04-04 02:50:49 +02:00
|
|
|
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView])
|
|
|
|
|
|
2026-04-18 20:31:25 +02:00
|
|
|
const handleUpdateViewDefinition = useCallback(async (filename: string, patch: Partial<ViewDefinition>) => {
|
|
|
|
|
const existing = vault.views.find((view) => view.filename === filename)
|
|
|
|
|
if (!existing) return
|
|
|
|
|
|
|
|
|
|
const target = isTauri() ? invoke : mockInvoke
|
|
|
|
|
await target('save_view_cmd', {
|
|
|
|
|
vaultPath: resolvedPath,
|
|
|
|
|
filename,
|
|
|
|
|
definition: { ...existing.definition, ...patch },
|
|
|
|
|
})
|
|
|
|
|
await vault.reloadViews()
|
|
|
|
|
}, [resolvedPath, vault])
|
|
|
|
|
|
2026-04-04 02:50:49 +02:00
|
|
|
const handleEditView = useCallback((filename: string) => {
|
|
|
|
|
const view = vault.views.find((v) => v.filename === filename)
|
|
|
|
|
if (view) dialogs.openEditView(filename, view.definition)
|
|
|
|
|
}, [vault.views, dialogs])
|
2026-04-02 20:54:06 +02:00
|
|
|
|
|
|
|
|
const handleDeleteView = useCallback(async (filename: string) => {
|
|
|
|
|
const target = isTauri() ? invoke : mockInvoke
|
|
|
|
|
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
|
|
|
|
await vault.reloadViews()
|
2026-04-04 19:38:39 +02:00
|
|
|
await vault.reloadVault()
|
2026-04-04 11:19:44 +02:00
|
|
|
vault.reloadFolders()
|
2026-04-02 20:54:06 +02:00
|
|
|
if (selection.kind === 'view' && selection.filename === filename) {
|
|
|
|
|
handleSetSelection({ kind: 'filter', filter: 'all' })
|
|
|
|
|
}
|
|
|
|
|
setToastMessage('View deleted')
|
|
|
|
|
}, [resolvedPath, vault, selection, handleSetSelection])
|
|
|
|
|
|
|
|
|
|
const availableFields = useMemo(() => {
|
2026-04-04 16:13:21 +02:00
|
|
|
const builtIn = ['type', 'status', 'title', 'favorite', 'body']
|
2026-04-02 20:54:06 +02:00
|
|
|
if (!vault.entries?.length) return builtIn
|
2026-04-03 16:52:33 +02:00
|
|
|
const customFields = new Set<string>()
|
2026-04-02 20:54:06 +02:00
|
|
|
for (const e of vault.entries) {
|
|
|
|
|
if (e.properties) {
|
2026-04-03 16:52:33 +02:00
|
|
|
for (const key of Object.keys(e.properties)) customFields.add(key)
|
2026-04-02 20:54:06 +02:00
|
|
|
}
|
2026-04-03 16:52:33 +02:00
|
|
|
if (e.relationships) {
|
|
|
|
|
for (const key of Object.keys(e.relationships)) customFields.add(key)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return [...builtIn, ...Array.from(customFields).sort()]
|
|
|
|
|
}, [vault.entries])
|
|
|
|
|
|
2026-04-16 00:38:43 +02:00
|
|
|
const bulkActions = useBulkActions(entryActions, vault.entries, setToastMessage)
|
2026-02-27 15:14:21 +01:00
|
|
|
|
2026-03-02 18:38:25 +01:00
|
|
|
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
|
|
|
|
|
const rawToggleRef = useRef<() => void>(() => {})
|
2026-03-03 02:00:48 +01:00
|
|
|
// Diff-toggle ref: Editor registers its handleToggleDiff here so the command palette can call it
|
|
|
|
|
const diffToggleRef = useRef<() => void>(() => {})
|
2026-03-02 18:38:25 +01:00
|
|
|
|
2026-03-30 18:30:38 +02:00
|
|
|
const { setViewMode, sidebarVisible, noteListVisible } = useViewMode(noteWindowParams ? 'editor-only' : undefined)
|
2026-02-28 12:41:57 +01:00
|
|
|
const zoom = useZoom()
|
2026-03-02 01:50:41 +01:00
|
|
|
const buildNumber = useBuildNumber()
|
2026-02-23 12:01:46 +01:00
|
|
|
|
2026-04-16 05:03:01 +02:00
|
|
|
const updateMainWindowConstraints = useCallback((
|
|
|
|
|
nextSidebarVisible: boolean,
|
|
|
|
|
nextNoteListVisible: boolean,
|
|
|
|
|
nextInspectorCollapsed: boolean = layout.inspectorCollapsed,
|
|
|
|
|
) => {
|
|
|
|
|
if (noteWindowParams) return
|
|
|
|
|
|
|
|
|
|
const minWidth = getMainWindowMinWidth({
|
|
|
|
|
sidebarVisible: nextSidebarVisible,
|
|
|
|
|
noteListVisible: nextNoteListVisible,
|
|
|
|
|
inspectorCollapsed: nextInspectorCollapsed,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
void applyMainWindowSizeConstraints(minWidth).catch(() => {})
|
|
|
|
|
}, [layout.inspectorCollapsed, noteWindowParams])
|
|
|
|
|
|
|
|
|
|
const handleSetViewMode = useCallback((mode: ViewMode) => {
|
|
|
|
|
setViewMode(mode)
|
|
|
|
|
updateMainWindowConstraints(mode === 'all', mode !== 'editor-only')
|
|
|
|
|
}, [setViewMode, updateMainWindowConstraints])
|
|
|
|
|
|
|
|
|
|
const handleToggleInspector = useCallback(() => {
|
|
|
|
|
const nextInspectorCollapsed = !layout.inspectorCollapsed
|
|
|
|
|
layout.setInspectorCollapsed(nextInspectorCollapsed)
|
|
|
|
|
updateMainWindowConstraints(sidebarVisible, noteListVisible, nextInspectorCollapsed)
|
|
|
|
|
}, [
|
|
|
|
|
layout,
|
|
|
|
|
noteListVisible,
|
|
|
|
|
sidebarVisible,
|
|
|
|
|
updateMainWindowConstraints,
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
useMainWindowSizeConstraints({
|
|
|
|
|
enabled: !noteWindowParams,
|
|
|
|
|
sidebarVisible,
|
|
|
|
|
noteListVisible,
|
|
|
|
|
inspectorCollapsed: layout.inspectorCollapsed,
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-12 22:14:53 +02:00
|
|
|
const { status: updateStatus, actions: updateActions } = useUpdater(settings.release_channel)
|
2026-03-02 23:48:01 +01:00
|
|
|
|
|
|
|
|
const handleCheckForUpdates = useCallback(async () => {
|
2026-03-03 16:46:47 +01:00
|
|
|
if (updateStatus.state === 'downloading') {
|
|
|
|
|
setToastMessage('Update is downloading…')
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (updateStatus.state === 'ready') {
|
|
|
|
|
await restartApp()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-02 23:48:01 +01:00
|
|
|
const result = await updateActions.checkForUpdates()
|
|
|
|
|
if (result === 'up-to-date') {
|
|
|
|
|
setToastMessage("You're on the latest version")
|
|
|
|
|
} else if (result === 'error') {
|
|
|
|
|
setToastMessage('Could not check for updates')
|
|
|
|
|
}
|
2026-03-03 16:46:47 +01:00
|
|
|
}, [updateActions, updateStatus.state, setToastMessage])
|
2026-03-02 23:48:01 +01:00
|
|
|
|
2026-03-07 12:39:55 +01:00
|
|
|
const handleRepairVault = useCallback(async () => {
|
|
|
|
|
if (!resolvedPath) return
|
|
|
|
|
try {
|
|
|
|
|
const tauriInvoke = isTauri() ? invoke : mockInvoke
|
|
|
|
|
const msg = await tauriInvoke<string>('repair_vault', { vaultPath: resolvedPath })
|
|
|
|
|
await vault.reloadVault()
|
2026-04-14 14:16:55 +02:00
|
|
|
await refreshVaultAiGuidance()
|
2026-03-07 12:39:55 +01:00
|
|
|
setToastMessage(msg)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setToastMessage(`Failed to repair vault: ${err}`)
|
|
|
|
|
}
|
2026-04-14 14:16:55 +02:00
|
|
|
}, [refreshVaultAiGuidance, resolvedPath, vault, setToastMessage])
|
|
|
|
|
|
|
|
|
|
const restoreVaultAiGuidance = useCallback(async (successToast: string | null = 'Tolaria AI guidance restored') => {
|
|
|
|
|
if (!resolvedPath) return
|
|
|
|
|
try {
|
|
|
|
|
const tauriInvoke = isTauri() ? invoke : mockInvoke
|
|
|
|
|
await tauriInvoke('restore_vault_ai_guidance', { vaultPath: resolvedPath })
|
|
|
|
|
await vault.reloadVault()
|
|
|
|
|
await refreshVaultAiGuidance()
|
|
|
|
|
if (successToast) setToastMessage(successToast)
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setToastMessage(`Failed to restore Tolaria AI guidance: ${err}`)
|
|
|
|
|
}
|
|
|
|
|
}, [refreshVaultAiGuidance, resolvedPath, vault, setToastMessage])
|
2026-03-07 12:39:55 +01:00
|
|
|
|
2026-04-07 20:09:04 +02:00
|
|
|
const activeDeletedFile = useMemo(() => {
|
2026-04-07 20:10:13 +02:00
|
|
|
const activeTabPath = notes.activeTabPath
|
|
|
|
|
if (!activeTabPath) return null
|
2026-04-07 20:09:04 +02:00
|
|
|
return vault.modifiedFiles.find((file) =>
|
|
|
|
|
file.status === 'deleted'
|
2026-04-07 20:10:13 +02:00
|
|
|
&& (file.path === activeTabPath || activeTabPath.endsWith('/' + file.relativePath)),
|
2026-04-07 20:09:04 +02:00
|
|
|
) ?? null
|
|
|
|
|
}, [notes.activeTabPath, vault.modifiedFiles])
|
|
|
|
|
|
2026-04-17 23:32:36 +02:00
|
|
|
const activeCommandEntry = useMemo(() => {
|
|
|
|
|
if (!notes.activeTabPath) return null
|
|
|
|
|
return notes.tabs.find((tab) => tab.entry.path === notes.activeTabPath)?.entry
|
|
|
|
|
?? vault.entries.find((entry) => entry.path === notes.activeTabPath)
|
|
|
|
|
?? null
|
|
|
|
|
}, [notes.activeTabPath, notes.tabs, vault.entries])
|
|
|
|
|
|
|
|
|
|
const canToggleRichEditor = !!activeCommandEntry
|
|
|
|
|
&& activeCommandEntry.filename.toLowerCase().endsWith('.md')
|
|
|
|
|
&& !activeDeletedFile
|
|
|
|
|
|
2026-04-18 20:31:25 +02:00
|
|
|
const noteListColumnsLabel = useMemo(() => {
|
|
|
|
|
if (effectiveSelection.kind === 'view') {
|
|
|
|
|
const selectedView = vault.views.find((view) => view.filename === effectiveSelection.filename)
|
|
|
|
|
return selectedView ? `Customize ${selectedView.definition.name} columns` : 'Customize View columns'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'all'
|
|
|
|
|
? 'Customize All Notes columns'
|
|
|
|
|
: 'Customize Inbox columns'
|
|
|
|
|
}, [effectiveSelection, vault.views])
|
|
|
|
|
|
2026-02-25 13:31:19 +01:00
|
|
|
const commands = useAppCommands({
|
|
|
|
|
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
2026-03-08 22:15:08 +01:00
|
|
|
entries: vault.entries,
|
2026-04-06 10:29:27 +02:00
|
|
|
visibleNotesRef,
|
2026-04-16 01:36:44 +02:00
|
|
|
multiSelectionCommandRef,
|
2026-03-03 02:00:48 +01:00
|
|
|
modifiedCount: vault.modifiedFiles.length,
|
|
|
|
|
activeNoteModified: vault.modifiedFiles.some(f => f.path === notes.activeTabPath),
|
2026-04-10 13:52:39 +02:00
|
|
|
selection: effectiveSelection,
|
2026-02-25 13:31:19 +01:00
|
|
|
onQuickOpen: dialogs.openQuickOpen, onCommandPalette: dialogs.openCommandPalette,
|
feat: full-text search with qmd backend and SearchPanel UI (Cmd+Shift+F) (#64)
* feat: add search backend (qmd CLI) and design file
- Add search.rs: qmd integration for keyword (BM25), semantic (vsearch),
and hybrid search modes via CLI JSON output
- Register search_vault Tauri command in lib.rs
- Design file with 3 frames: empty, results, no-results states
- Fix pre-existing test failures: install @tauri-apps/plugin-opener,
add mock in test setup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add SearchPanel UI with Cmd+Shift+F shortcut
- SearchPanel component: full-text search overlay with keyword/semantic
mode toggle, debounced search (200ms), arrow key navigation, result
count + elapsed time display, note type badges from vault entries
- Cmd+Shift+F shortcut registered in useAppKeyboard
- Mock search_vault handler in mock-tauri for browser dev mode
- SearchResult/SearchResponse types in types.ts
- Tests: 15 new tests for SearchPanel + 2 for keyboard shortcut
- scrollIntoView mock in test setup for jsdom compatibility
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: correct SearchPanel imports for Tauri/browser dual-mode
Use invoke from @tauri-apps/api/core and mockInvoke from mock-tauri
with a searchCall wrapper, fixing the TypeScript build error.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: make test_detect_collection_fallback robust when qmd not installed
* fix: reset localStorage between App tests to prevent view mode state leak
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 17:09:50 +01:00
|
|
|
onSearch: dialogs.openSearch,
|
2026-02-28 19:13:29 +01:00
|
|
|
onCreateNote: notes.handleCreateNoteImmediate,
|
|
|
|
|
onCreateNoteOfType: notes.handleCreateNoteImmediate,
|
2026-03-29 10:35:13 +02:00
|
|
|
onSave: appSave.handleSave,
|
2026-02-25 13:31:19 +01:00
|
|
|
onOpenSettings: dialogs.openSettings,
|
2026-04-10 12:45:37 +02:00
|
|
|
onOpenFeedback: openFeedback,
|
2026-04-06 12:21:56 +02:00
|
|
|
onDeleteNote: deleteActions.handleDeleteNote,
|
2026-03-02 11:55:51 +01:00
|
|
|
onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote,
|
2026-04-18 09:58:49 +02:00
|
|
|
onCommitPush: handleCommitPush,
|
2026-03-19 09:51:06 +01:00
|
|
|
onPull: autoSync.triggerSync,
|
2026-03-29 10:35:13 +02:00
|
|
|
onResolveConflicts: conflictFlow.handleOpenConflictResolver,
|
2026-04-16 05:03:01 +02:00
|
|
|
onSetViewMode: handleSetViewMode,
|
|
|
|
|
onToggleInspector: handleToggleInspector,
|
2026-03-03 02:00:48 +01:00
|
|
|
onToggleDiff: () => diffToggleRef.current(),
|
2026-04-17 23:32:36 +02:00
|
|
|
onToggleRawEditor: canToggleRichEditor ? () => rawToggleRef.current() : undefined,
|
2026-02-28 12:41:57 +01:00
|
|
|
onZoomIn: zoom.zoomIn, onZoomOut: zoom.zoomOut, onZoomReset: zoom.zoomReset,
|
|
|
|
|
zoomLevel: zoom.zoomLevel,
|
refactor: remove tab bar — single note open at a time
Replace the multi-tab model with single-note navigation. One note is
open at a time; clicking any note (sidebar, wikilink, relationship)
replaces the current note in the editor. Back/Forward history still
works via Cmd+[/].
Removed: TabBar component, useClosedTabHistory, tab reorder/close/
reopen logic, Cmd+W/Cmd+Shift+T shortcuts, Close Tab and Reopen
Closed Tab menu items, tabLayout utility, and all related tests.
Simplified: useTabManagement (single-note), useAppNavigation (no tab
lookup), useKeyboardNavigation (note nav only), useNoteCreation (no
close-tab cleanup), useDeleteActions (deselect instead of close tab),
Editor (no TabBar render), Rust menu (removed 2 menu items).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:24:49 +01:00
|
|
|
onSelect: handleSetSelection,
|
2026-04-10 13:52:39 +02:00
|
|
|
showInbox: explicitOrganizationEnabled,
|
refactor: remove tab bar — single note open at a time
Replace the multi-tab model with single-note navigation. One note is
open at a time; clicking any note (sidebar, wikilink, relationship)
replaces the current note in the editor. Back/Forward history still
works via Cmd+[/].
Removed: TabBar component, useClosedTabHistory, tab reorder/close/
reopen logic, Cmd+W/Cmd+Shift+T shortcuts, Close Tab and Reopen
Closed Tab menu items, tabLayout utility, and all related tests.
Simplified: useTabManagement (single-note), useAppNavigation (no tab
lookup), useKeyboardNavigation (note nav only), useNoteCreation (no
close-tab cleanup), useDeleteActions (deselect instead of close tab),
Editor (no TabBar render), Rust menu (removed 2 menu items).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:24:49 +01:00
|
|
|
onReplaceActiveTab: notes.handleReplaceActiveTab,
|
2026-02-25 13:31:19 +01:00
|
|
|
onSelectNote: notes.handleSelectNote,
|
2026-02-25 19:39:12 +01:00
|
|
|
onGoBack: handleGoBack, onGoForward: handleGoForward,
|
2026-03-12 07:24:46 +01:00
|
|
|
canGoBack: canGoBack, canGoForward: canGoForward,
|
2026-03-01 16:04:51 +01:00
|
|
|
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
|
2026-04-19 01:06:40 +02:00
|
|
|
onCreateEmptyVault: vaultSwitcher.handleCreateEmptyVault,
|
2026-03-03 00:31:10 +01:00
|
|
|
onCreateType: dialogs.openCreateType,
|
2026-03-02 05:00:29 +01:00
|
|
|
onToggleAIChat: dialogs.toggleAIChat,
|
2026-03-02 23:48:01 +01:00
|
|
|
onCheckForUpdates: handleCheckForUpdates,
|
2026-03-03 02:26:41 +01:00
|
|
|
onRemoveActiveVault: () => vaultSwitcher.removeVault(vaultSwitcher.vaultPath),
|
2026-04-15 14:55:56 +02:00
|
|
|
onRestoreGettingStarted: cloneGettingStartedVault,
|
2026-03-03 02:26:41 +01:00
|
|
|
isGettingStartedHidden: vaultSwitcher.isGettingStartedHidden,
|
|
|
|
|
vaultCount: vaultSwitcher.allVaults.length,
|
2026-03-04 13:11:58 +01:00
|
|
|
mcpStatus,
|
|
|
|
|
onInstallMcp: installMcp,
|
2026-04-13 19:37:59 +02:00
|
|
|
onOpenAiAgents: dialogs.openSettings,
|
2026-04-14 11:55:48 +02:00
|
|
|
aiAgentsStatus,
|
2026-04-14 14:16:55 +02:00
|
|
|
vaultAiGuidanceStatus,
|
|
|
|
|
onRestoreVaultAiGuidance: () => { void restoreVaultAiGuidance() },
|
2026-04-14 11:55:48 +02:00
|
|
|
selectedAiAgent: aiAgentPreferences.defaultAiAgent,
|
|
|
|
|
onSetDefaultAiAgent: aiAgentPreferences.setDefaultAiAgent,
|
2026-04-13 19:37:59 +02:00
|
|
|
onCycleDefaultAiAgent: aiAgentPreferences.cycleDefaultAiAgent,
|
|
|
|
|
selectedAiAgentLabel: aiAgentPreferences.defaultAiAgentLabel,
|
2026-03-09 00:35:21 +01:00
|
|
|
onReloadVault: vault.reloadVault,
|
2026-03-07 12:39:55 +01:00
|
|
|
onRepairVault: handleRepairVault,
|
2026-03-17 22:42:55 +01:00
|
|
|
onSetNoteIcon: handleSetNoteIconCommand,
|
|
|
|
|
onRemoveNoteIcon: handleRemoveNoteIconCommand,
|
|
|
|
|
activeNoteHasIcon: (() => {
|
|
|
|
|
const ae = vault.entries.find(e => e.path === notes.activeTabPath)
|
2026-04-07 21:09:06 +02:00
|
|
|
return hasNoteIconValue(ae?.icon)
|
2026-03-17 22:42:55 +01:00
|
|
|
})(),
|
2026-03-18 03:43:23 +01:00
|
|
|
noteListFilter,
|
|
|
|
|
onSetNoteListFilter: setNoteListFilter,
|
2026-03-19 08:47:25 +01:00
|
|
|
onOpenInNewWindow: handleOpenInNewWindow,
|
2026-04-03 17:20:33 +02:00
|
|
|
onToggleFavorite: entryActions.handleToggleFavorite,
|
2026-04-10 13:52:39 +02:00
|
|
|
onToggleOrganized: explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined,
|
2026-04-16 02:18:43 +02:00
|
|
|
onCustomizeNoteListColumns: handleCustomizeNoteListColumns,
|
2026-04-18 20:31:25 +02:00
|
|
|
canCustomizeNoteListColumns: effectiveSelection.kind === 'view'
|
|
|
|
|
|| (
|
|
|
|
|
effectiveSelection.kind === 'filter'
|
|
|
|
|
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox'))
|
|
|
|
|
),
|
|
|
|
|
noteListColumnsLabel,
|
2026-04-07 20:09:04 +02:00
|
|
|
onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
|
|
|
|
|
canRestoreDeletedNote: !!activeDeletedFile,
|
2026-02-24 23:41:54 +01:00
|
|
|
})
|
2026-02-22 12:10:24 +01:00
|
|
|
|
2026-02-17 12:10:21 +01:00
|
|
|
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
|
2026-02-15 13:00:10 +01:00
|
|
|
|
2026-03-19 06:41:40 +01:00
|
|
|
const inboxCount = useMemo(() => filterInboxEntries(vault.entries, inboxPeriod).length, [vault.entries, inboxPeriod])
|
|
|
|
|
|
2026-03-04 19:53:46 +01:00
|
|
|
const aiNoteList = useMemo<NoteListItem[]>(() => {
|
2026-04-10 13:52:39 +02:00
|
|
|
const isInbox = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'inbox'
|
|
|
|
|
const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, effectiveSelection, undefined, vault.views)
|
2026-03-19 06:41:40 +01:00
|
|
|
return filtered.map(e => ({
|
2026-03-04 19:53:46 +01:00
|
|
|
path: e.path, title: e.title, type: e.isA ?? 'Note',
|
|
|
|
|
}))
|
2026-04-10 13:52:39 +02:00
|
|
|
}, [vault.entries, vault.views, effectiveSelection, inboxPeriod])
|
2026-03-04 19:53:46 +01:00
|
|
|
|
|
|
|
|
const aiNoteListFilter = useMemo(() => {
|
2026-04-10 13:52:39 +02:00
|
|
|
if (effectiveSelection.kind === 'sectionGroup') return { type: effectiveSelection.type, query: '' }
|
|
|
|
|
if (effectiveSelection.kind === 'entity') return { type: null, query: effectiveSelection.entry.title }
|
2026-03-04 19:53:46 +01:00
|
|
|
return { type: null, query: '' }
|
2026-04-10 13:52:39 +02:00
|
|
|
}, [effectiveSelection])
|
2026-03-04 19:53:46 +01:00
|
|
|
|
2026-04-18 00:21:52 +02:00
|
|
|
const shouldResumeFreshStartOnboarding = useMemo(() => {
|
|
|
|
|
if (onboarding.state.status !== 'ready' || !vaultSwitcher.loaded) return false
|
2026-04-18 03:10:41 +02:00
|
|
|
const remembersOnlyDefaultVault = selectedVaultPath === null || selectedVaultPath === defaultPath
|
2026-04-18 00:21:52 +02:00
|
|
|
|
2026-04-18 03:10:41 +02:00
|
|
|
return remembersOnlyDefaultVault
|
|
|
|
|
&& vaultSwitcher.allVaults.length === 1
|
2026-04-18 00:21:52 +02:00
|
|
|
&& vaultSwitcher.allVaults[0]?.path === vaultSwitcher.vaultPath
|
|
|
|
|
&& onboarding.state.vaultPath === vaultSwitcher.vaultPath
|
2026-04-18 03:10:41 +02:00
|
|
|
}, [defaultPath, onboarding.state, selectedVaultPath, vaultSwitcher.allVaults, vaultSwitcher.loaded, vaultSwitcher.vaultPath])
|
feat: integrate welcome screen with tests and fix App tests
WelcomeScreen component (14 tests):
- welcome mode: title, buttons, hint, loading, error states
- vault-missing mode: path badge, different button labels
useOnboarding hook (10 tests):
- vault exists → ready, vault missing → welcome
- dismissed + missing → vault-missing
- create vault, open folder, dismiss transitions
- error handling, picker cancellation, command failure fallback
App.test.tsx updated to mock new Tauri commands
(check_vault_exists, get_default_vault_path).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:55:35 +01:00
|
|
|
|
2026-03-30 18:30:38 +02:00
|
|
|
// Show loading spinner while checking vault (skip for note windows)
|
|
|
|
|
if (!noteWindowParams && onboarding.state.status === 'loading') {
|
2026-03-17 07:13:49 +01:00
|
|
|
return <LoadingView />
|
feat: integrate welcome screen with tests and fix App tests
WelcomeScreen component (14 tests):
- welcome mode: title, buttons, hint, loading, error states
- vault-missing mode: path badge, different button labels
useOnboarding hook (10 tests):
- vault exists → ready, vault missing → welcome
- dismissed + missing → vault-missing
- create vault, open folder, dismiss transitions
- error handling, picker cancellation, command failure fallback
App.test.tsx updated to mock new Tauri commands
(check_vault_exists, get_default_vault_path).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:55:35 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-18 00:21:52 +02:00
|
|
|
// Show telemetry consent dialog on first launch (skip for note windows).
|
|
|
|
|
// After the user answers, the next render can continue into onboarding.
|
|
|
|
|
if (!noteWindowParams && settingsLoaded && settings.telemetry_consent === null) {
|
|
|
|
|
return (
|
|
|
|
|
<TelemetryConsentDialog
|
|
|
|
|
onAccept={() => {
|
|
|
|
|
const id = crypto.randomUUID()
|
|
|
|
|
saveSettings({ ...settings, telemetry_consent: true, crash_reporting_enabled: true, analytics_enabled: true, anonymous_id: id })
|
|
|
|
|
}}
|
|
|
|
|
onDecline={() => {
|
|
|
|
|
saveSettings({ ...settings, telemetry_consent: false, crash_reporting_enabled: false, analytics_enabled: false, anonymous_id: null })
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Show welcome/onboarding screen when vault doesn't exist (skip for note windows — vault path is known)
|
|
|
|
|
if (!noteWindowParams && (onboarding.state.status === 'welcome' || onboarding.state.status === 'vault-missing' || shouldResumeFreshStartOnboarding)) {
|
|
|
|
|
const welcomeOnboarding = shouldResumeFreshStartOnboarding
|
|
|
|
|
? { ...onboarding, state: { status: 'welcome' as const, defaultPath: vaultSwitcher.vaultPath } }
|
|
|
|
|
: onboarding
|
|
|
|
|
return <WelcomeView onboarding={welcomeOnboarding} isOffline={networkStatus.isOffline} />
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 19:37:59 +02:00
|
|
|
if (!noteWindowParams && onboarding.state.status === 'ready' && aiAgentsOnboarding.showPrompt) {
|
2026-04-12 19:56:10 +02:00
|
|
|
return (
|
2026-04-15 14:55:56 +02:00
|
|
|
<>
|
|
|
|
|
<AiAgentsOnboardingView
|
|
|
|
|
statuses={aiAgentsStatus}
|
|
|
|
|
onContinue={aiAgentsOnboarding.dismissPrompt}
|
|
|
|
|
/>
|
|
|
|
|
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
|
|
|
|
</>
|
2026-04-12 19:56:10 +02:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 11:30:39 +02:00
|
|
|
// Show git-required modal when vault has no git repo (skip for note windows)
|
|
|
|
|
if (!noteWindowParams && gitRepoState === 'required') {
|
|
|
|
|
return (
|
|
|
|
|
<div className="app-shell">
|
|
|
|
|
<GitRequiredModal
|
|
|
|
|
onCreateRepo={handleInitGitRepo}
|
|
|
|
|
onChooseVault={vaultSwitcher.handleOpenLocalFolder}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Show loading spinner while checking git status
|
|
|
|
|
if (!noteWindowParams && gitRepoState === 'checking' && onboarding.state.status === 'ready') {
|
|
|
|
|
return <LoadingView />
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 18:20:07 +01:00
|
|
|
return (
|
2026-02-17 11:03:23 +01:00
|
|
|
<div className="app-shell">
|
|
|
|
|
<div className="app">
|
2026-02-25 18:30:12 +01:00
|
|
|
{sidebarVisible && (
|
|
|
|
|
<>
|
|
|
|
|
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
2026-04-19 21:27:32 +02:00
|
|
|
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} />
|
2026-02-25 18:30:12 +01:00
|
|
|
</div>
|
|
|
|
|
<ResizeHandle onResize={layout.handleSidebarResize} />
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
{noteListVisible && (
|
|
|
|
|
<>
|
2026-03-04 10:05:43 +01:00
|
|
|
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
|
2026-04-10 13:52:39 +02:00
|
|
|
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
|
2026-04-16 05:03:01 +02:00
|
|
|
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
|
2026-03-05 16:27:29 +01:00
|
|
|
) : (
|
2026-04-19 03:37:33 +02:00
|
|
|
<NoteList entries={vault.entries} selection={effectiveSelection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
|
2026-03-05 16:27:29 +01:00
|
|
|
)}
|
2026-02-25 18:30:12 +01:00
|
|
|
</div>
|
|
|
|
|
<ResizeHandle onResize={layout.handleNoteListResize} />
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-03-04 10:05:43 +01:00
|
|
|
<div className={`app__editor${aiActivity.highlightElement === 'editor' || aiActivity.highlightElement === 'tab' ? ' ai-highlight' : ''}`}>
|
2026-02-17 11:03:23 +01:00
|
|
|
<Editor
|
2026-02-17 12:10:21 +01:00
|
|
|
tabs={notes.tabs}
|
|
|
|
|
activeTabPath={notes.activeTabPath}
|
|
|
|
|
entries={vault.entries}
|
|
|
|
|
onNavigateWikilink={notes.handleNavigateWikilink}
|
|
|
|
|
onLoadDiff={vault.loadDiff}
|
2026-02-21 22:27:18 +01:00
|
|
|
onLoadDiffAtCommit={vault.loadDiffAtCommit}
|
2026-04-15 17:57:38 +02:00
|
|
|
pendingCommitDiffRequest={pulseCommitDiffRequest}
|
|
|
|
|
onPendingCommitDiffHandled={handlePulseCommitDiffHandled}
|
2026-02-24 15:54:24 +01:00
|
|
|
getNoteStatus={vault.getNoteStatus}
|
2026-02-25 13:31:19 +01:00
|
|
|
onCreateNote={notes.handleCreateNoteImmediate}
|
2026-02-22 10:48:13 +01:00
|
|
|
inspectorCollapsed={layout.inspectorCollapsed}
|
2026-04-16 05:03:01 +02:00
|
|
|
onToggleInspector={handleToggleInspector}
|
2026-02-22 10:48:13 +01:00
|
|
|
inspectorWidth={layout.inspectorWidth}
|
2026-04-13 19:37:59 +02:00
|
|
|
defaultAiAgent={aiAgentPreferences.defaultAiAgent}
|
|
|
|
|
defaultAiAgentReady={aiAgentPreferences.defaultAiAgentReady}
|
2026-02-22 10:48:13 +01:00
|
|
|
onInspectorResize={layout.handleInspectorResize}
|
2026-02-17 11:03:23 +01:00
|
|
|
inspectorEntry={activeTab?.entry ?? null}
|
|
|
|
|
inspectorContent={activeTab?.content ?? null}
|
|
|
|
|
gitHistory={gitHistory}
|
2026-02-17 12:10:21 +01:00
|
|
|
onUpdateFrontmatter={notes.handleUpdateFrontmatter}
|
|
|
|
|
onDeleteProperty={notes.handleDeleteProperty}
|
|
|
|
|
onAddProperty={notes.handleAddProperty}
|
2026-04-17 00:44:14 +02:00
|
|
|
onCreateMissingType={handleCreateMissingType}
|
2026-03-11 21:24:57 +01:00
|
|
|
onCreateAndOpenNote={notes.handleCreateNoteForRelationship}
|
2026-03-31 11:07:39 +02:00
|
|
|
onInitializeProperties={handleInitializeProperties}
|
2026-02-25 13:31:19 +01:00
|
|
|
showAIChat={dialogs.showAIChat}
|
|
|
|
|
onToggleAIChat={dialogs.toggleAIChat}
|
feat: integrate welcome screen with tests and fix App tests
WelcomeScreen component (14 tests):
- welcome mode: title, buttons, hint, loading, error states
- vault-missing mode: path badge, different button labels
useOnboarding hook (10 tests):
- vault exists → ready, vault missing → welcome
- dismissed + missing → vault-missing
- create vault, open folder, dismiss transitions
- error handling, picker cancellation, command failure fallback
App.test.tsx updated to mock new Tauri commands
(check_vault_exists, get_default_vault_path).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:55:35 +01:00
|
|
|
vaultPath={resolvedPath}
|
2026-03-04 19:53:46 +01:00
|
|
|
noteList={aiNoteList}
|
|
|
|
|
noteListFilter={aiNoteListFilter}
|
2026-04-07 20:09:04 +02:00
|
|
|
onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite}
|
2026-04-10 13:52:39 +02:00
|
|
|
onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : entryActions.handleToggleOrganized}
|
2026-04-07 20:09:04 +02:00
|
|
|
onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote}
|
|
|
|
|
onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote}
|
|
|
|
|
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
|
2026-04-16 23:38:30 +02:00
|
|
|
onContentChange={handleTrackedContentChange}
|
|
|
|
|
onSave={handleTrackedSave}
|
2026-04-14 15:40:46 +02:00
|
|
|
onRenameFilename={activeDeletedFile ? undefined : appSave.handleFilenameRename}
|
2026-03-02 18:38:25 +01:00
|
|
|
rawToggleRef={rawToggleRef}
|
2026-03-03 02:00:48 +01:00
|
|
|
diffToggleRef={diffToggleRef}
|
2026-03-12 07:24:46 +01:00
|
|
|
canGoBack={canGoBack}
|
|
|
|
|
canGoForward={canGoForward}
|
2026-02-25 19:39:12 +01:00
|
|
|
onGoBack={handleGoBack}
|
|
|
|
|
onGoForward={handleGoForward}
|
2026-02-28 20:19:03 +01:00
|
|
|
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
|
2026-03-29 10:35:13 +02:00
|
|
|
onFileCreated={vaultBridge.handleAgentFileCreated}
|
|
|
|
|
onFileModified={vaultBridge.handleAgentFileModified}
|
|
|
|
|
onVaultChanged={vaultBridge.handleAgentVaultChanged}
|
|
|
|
|
isConflicted={conflictFlow.isConflicted}
|
|
|
|
|
onKeepMine={conflictFlow.handleKeepMine}
|
|
|
|
|
onKeepTheirs={conflictFlow.handleKeepTheirs}
|
2026-04-18 09:19:29 +02:00
|
|
|
flushPendingRawContentRef={flushPendingRawContentRef}
|
2026-02-17 11:03:23 +01:00
|
|
|
/>
|
|
|
|
|
</div>
|
2026-02-14 18:20:07 +01:00
|
|
|
</div>
|
2026-03-01 17:01:22 +01:00
|
|
|
<UpdateBanner status={updateStatus} actions={updateActions} />
|
2026-03-31 11:58:32 +02:00
|
|
|
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
|
2026-04-19 01:06:40 +02:00
|
|
|
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} 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} isOffline={networkStatus.isOffline} isGitVault={isGitVault} 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} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
|
2026-04-14 10:53:08 +02:00
|
|
|
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
|
2026-02-14 21:19:38 +01:00
|
|
|
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
2026-02-25 13:31:19 +01:00
|
|
|
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
2026-04-12 13:14:18 +02:00
|
|
|
<CommandPalette
|
|
|
|
|
open={dialogs.showCommandPalette}
|
|
|
|
|
commands={commands}
|
|
|
|
|
entries={vault.entries}
|
2026-04-13 19:37:59 +02:00
|
|
|
aiAgentReady={aiAgentPreferences.defaultAiAgentReady}
|
|
|
|
|
aiAgentLabel={aiAgentPreferences.defaultAiAgentLabel}
|
2026-04-12 13:14:18 +02:00
|
|
|
onClose={dialogs.closeCommandPalette}
|
|
|
|
|
/>
|
feat: integrate welcome screen with tests and fix App tests
WelcomeScreen component (14 tests):
- welcome mode: title, buttons, hint, loading, error states
- vault-missing mode: path badge, different button labels
useOnboarding hook (10 tests):
- vault exists → ready, vault missing → welcome
- dismissed + missing → vault-missing
- create vault, open folder, dismiss transitions
- error handling, picker cancellation, command failure fallback
App.test.tsx updated to mock new Tauri commands
(check_vault_exists, get_default_vault_path).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 12:55:35 +01:00
|
|
|
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
2026-02-25 13:31:19 +01:00
|
|
|
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
2026-04-07 19:40:54 +02:00
|
|
|
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} editingView={dialogs.editingView?.definition ?? null} />
|
2026-04-12 19:38:25 +02:00
|
|
|
<CommitDialog
|
|
|
|
|
open={commitFlow.showCommitDialog}
|
|
|
|
|
modifiedCount={vault.modifiedFiles.length}
|
|
|
|
|
commitMode={commitFlow.commitMode}
|
|
|
|
|
suggestedMessage={suggestedCommitMessage}
|
|
|
|
|
onCommit={commitFlow.handleCommitPush}
|
|
|
|
|
onClose={commitFlow.closeCommitDialog}
|
|
|
|
|
/>
|
2026-03-03 02:27:42 +01:00
|
|
|
<ConflictResolverModal
|
|
|
|
|
open={dialogs.showConflictResolver}
|
|
|
|
|
fileStates={conflictResolver.fileStates}
|
|
|
|
|
allResolved={conflictResolver.allResolved}
|
|
|
|
|
committing={conflictResolver.committing}
|
|
|
|
|
error={conflictResolver.error}
|
|
|
|
|
onResolveFile={conflictResolver.resolveFile}
|
|
|
|
|
onOpenInEditor={conflictResolver.openInEditor}
|
|
|
|
|
onCommit={conflictResolver.commitResolution}
|
2026-03-29 10:35:13 +02:00
|
|
|
onClose={conflictFlow.handleCloseConflictResolver}
|
2026-03-03 02:27:42 +01:00
|
|
|
/>
|
2026-04-16 23:38:30 +02:00
|
|
|
<SettingsPanel open={dialogs.showSettings} settings={settings} aiAgentsStatus={aiAgentsStatus} isGitVault={isGitVault} onSave={saveSettings} explicitOrganizationEnabled={explicitOrganizationEnabled} onSaveExplicitOrganization={handleSaveExplicitOrganization} onClose={dialogs.closeSettings} />
|
2026-04-10 12:45:37 +02:00
|
|
|
<FeedbackDialog open={showFeedback} onClose={closeFeedback} />
|
2026-04-12 17:08:07 +02:00
|
|
|
<CloneVaultModal key={dialogs.showCloneVault ? 'clone-open' : 'clone-closed'} open={dialogs.showCloneVault} onClose={dialogs.closeCloneVault} onVaultCloned={vaultSwitcher.handleVaultCloned} />
|
2026-03-12 06:28:19 +01:00
|
|
|
{deleteActions.confirmDelete && (
|
2026-03-12 00:06:33 +01:00
|
|
|
<ConfirmDeleteDialog
|
|
|
|
|
open={true}
|
2026-03-12 06:28:19 +01:00
|
|
|
title={deleteActions.confirmDelete.title}
|
|
|
|
|
message={deleteActions.confirmDelete.message}
|
|
|
|
|
confirmLabel={deleteActions.confirmDelete.confirmLabel}
|
|
|
|
|
onConfirm={deleteActions.confirmDelete.onConfirm}
|
|
|
|
|
onCancel={() => deleteActions.setConfirmDelete(null)}
|
2026-03-12 00:06:33 +01:00
|
|
|
/>
|
|
|
|
|
)}
|
2026-02-14 18:22:42 +01:00
|
|
|
</div>
|
2026-02-14 18:20:07 +01:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 07:13:49 +01:00
|
|
|
type OnboardingState = ReturnType<typeof useOnboarding>
|
|
|
|
|
|
|
|
|
|
/** Welcome screen view - extracted from main App component */
|
2026-04-12 19:17:49 +02:00
|
|
|
function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; isOffline: boolean }) {
|
2026-03-17 07:13:49 +01:00
|
|
|
const state = onboarding.state as { status: 'welcome' | 'vault-missing'; defaultPath: string; vaultPath?: string }
|
|
|
|
|
return (
|
|
|
|
|
<div className="app-shell">
|
|
|
|
|
<WelcomeScreen
|
|
|
|
|
mode={state.status === 'welcome' ? 'welcome' : 'vault-missing'}
|
|
|
|
|
missingPath={state.status === 'vault-missing' ? state.vaultPath : undefined}
|
|
|
|
|
defaultVaultPath={state.defaultPath}
|
|
|
|
|
onCreateVault={onboarding.handleCreateVault}
|
2026-04-07 23:28:02 +02:00
|
|
|
onRetryCreateVault={onboarding.retryCreateVault}
|
2026-04-19 01:06:40 +02:00
|
|
|
onCreateEmptyVault={onboarding.handleCreateEmptyVault}
|
2026-03-17 07:13:49 +01:00
|
|
|
onOpenFolder={onboarding.handleOpenFolder}
|
2026-04-12 19:17:49 +02:00
|
|
|
isOffline={isOffline}
|
2026-04-07 23:28:02 +02:00
|
|
|
creatingAction={onboarding.creatingAction}
|
2026-03-17 07:13:49 +01:00
|
|
|
error={onboarding.error}
|
2026-04-07 23:28:02 +02:00
|
|
|
canRetryTemplate={onboarding.canRetryTemplate}
|
2026-03-17 07:13:49 +01:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 19:37:59 +02:00
|
|
|
function AiAgentsOnboardingView({
|
|
|
|
|
statuses,
|
2026-04-12 19:56:10 +02:00
|
|
|
onContinue,
|
|
|
|
|
}: {
|
2026-04-13 19:37:59 +02:00
|
|
|
statuses: ReturnType<typeof useAiAgentsStatus>
|
2026-04-12 19:56:10 +02:00
|
|
|
onContinue: () => void
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="app-shell">
|
2026-04-13 19:37:59 +02:00
|
|
|
<AiAgentsOnboardingPrompt statuses={statuses} onContinue={onContinue} />
|
2026-04-12 19:56:10 +02:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 07:13:49 +01:00
|
|
|
/** Loading spinner view - extracted from main App component */
|
|
|
|
|
function LoadingView() {
|
|
|
|
|
return (
|
|
|
|
|
<div className="app-shell">
|
|
|
|
|
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--sidebar)' }}>
|
|
|
|
|
<span style={{ color: 'var(--muted-foreground)', fontSize: 14 }}>Loading…</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-14 18:20:07 +01:00
|
|
|
export default App
|