From 1040d95eecba3d52a09e2c7653352a6707c5f318 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 16 May 2026 12:13:20 +0200 Subject: [PATCH] feat: add granular git controls --- docs/ABSTRACTIONS.md | 7 +- docs/ARCHITECTURE.md | 13 +- docs/GETTING-STARTED.md | 6 +- lara.lock | 5 +- src-tauri/src/settings.rs | 5 + src/App.tsx | 39 ++++-- src/components/GitRequiredModal.test.tsx | 8 ++ src/components/GitRequiredModal.tsx | 6 +- src/components/GitSettingsSection.tsx | 105 ++++++++++++++ src/components/SettingsPanel.test.tsx | 32 +++++ src/components/SettingsPanel.tsx | 90 ++---------- src/components/StatusBar.test.tsx | 22 +++ src/components/StatusBar.tsx | 3 + src/components/settingsPreferenceTracking.ts | 8 ++ .../status-bar/StatusBarSections.tsx | 13 +- src/hooks/commands/gitCommands.ts | 4 + src/hooks/useAppCommands.ts | 3 + src/hooks/useCommandRegistry.test.ts | 11 ++ src/hooks/useCommandRegistry.ts | 6 +- src/hooks/useGitSetupState.test.tsx | 74 ++++++++++ src/hooks/useGitSetupState.ts | 90 +++++++++--- src/hooks/useSettings.test.ts | 3 + src/hooks/useSettings.ts | 2 + src/hooks/useVaultConfig.ts | 1 + src/lib/gitSettings.ts | 5 + src/lib/locales/de-DE.json | 5 +- src/lib/locales/en.json | 5 +- src/lib/locales/es-419.json | 5 +- src/lib/locales/es-ES.json | 5 +- src/lib/locales/fr-FR.json | 5 +- src/lib/locales/it-IT.json | 5 +- src/lib/locales/ja-JP.json | 5 +- src/lib/locales/ko-KR.json | 5 +- src/lib/locales/pl-PL.json | 5 +- src/lib/locales/pt-BR.json | 5 +- src/lib/locales/pt-PT.json | 5 +- src/lib/locales/ru-RU.json | 5 +- src/lib/locales/vi.json | 5 +- src/lib/locales/zh-CN.json | 5 +- src/lib/locales/zh-TW.json | 5 +- src/lib/productAnalytics.ts | 6 + src/mock-tauri/mock-handlers.coverage.test.ts | 1 + src/mock-tauri/mock-handlers.ts | 2 + src/types.ts | 3 + src/utils/configMigration.ts | 128 ++++++++++-------- src/utils/vaultConfigStore.ts | 2 + 46 files changed, 587 insertions(+), 191 deletions(-) create mode 100644 src/components/GitSettingsSection.tsx create mode 100644 src/hooks/useGitSetupState.test.tsx create mode 100644 src/lib/gitSettings.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 8b71e84d..e87f708c 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -67,7 +67,7 @@ Git is a per-vault capability, not a prerequisite for the document model. A vaul | Git-backed | The vault path contains a Git repository | History, changes, commits, sync, conflict resolution, remotes, AutoGit, and auto-sync are available according to remote/config state | | Non-git | The vault path is a plain folder | Markdown scanning, editing, search, and navigation work; Git-dependent status-bar controls and command-palette entries are replaced by `Git disabled` + `Initialize Git for Current Vault` | -Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. Features that depend on Git must check this capability instead of assuming every vault has `.git`. +Plain folders become Git-backed only when the user explicitly runs Git initialization from the setup dialog, status bar, or command palette. The setup dialog supports "not now" for a one-time dismissal and "never for this vault" for a local per-vault opt-out from future automatic prompts. Features that depend on Git must check both the vault capability and the installation-local `git_enabled` setting instead of assuming every vault has `.git` or that Git chrome is globally visible. Git initialization is intentionally scoped to dedicated vault folders. When the current non-git folder looks like a broad personal root such as Documents, Desktop, or Downloads and does not already carry Tolaria-managed vault markers, `init_git_repo` refuses to run Git and asks the user to select or create a dedicated subfolder instead. @@ -776,7 +776,7 @@ Mounted workspaces are loaded together by `useVaultLoader` for note-list, quick- Per-vault settings stored locally and scoped by vault path: - Managed by `useVaultConfig` hook and `vaultConfigStore` -- Settings: zoom, view mode, editor mode, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, AI agent permission mode (`safe` / `power_user`) +- Settings: zoom, view mode, editor mode, tag colors, status colors, property display modes, Inbox/All Notes note-list column overrides, explicit organization workflow toggle, Git setup prompt preference, AI agent permission mode (`safe` / `power_user`) - Missing, null, and unknown AI agent permission modes normalize to `safe`; the AI panel can switch modes per vault, preserving the transcript and applying the new mode only to the next agent run - One-time migration from localStorage (`configMigration.ts`) @@ -850,6 +850,7 @@ interface Settings { note_width_mode: 'normal' | 'wide' | null sidebar_type_pluralization_enabled: boolean | null // null = default true ai_features_enabled: boolean | null // null = default true + git_enabled: boolean | null // null = default true default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | null default_ai_target: string | null // "agent:codex" or "model:/" ai_model_providers: AiModelProvider[] | null @@ -860,7 +861,7 @@ interface Settings { } ``` -Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation. +Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is installation-local because it controls device comfort rather than vault structure; the Settings panel and command-palette Light/Dark/System actions both update that same value. `system` remains a stored preference, while the runtime resolves it to `light` or `dark` for `data-theme` and app consumers. `ui_language` is also installation-local: `null` follows the supported system language with English fallback, while explicit values pin the UI language for this installation. Stored legacy aliases such as `zh-Hans` are normalized to canonical locale codes before the setting reaches React state. `date_display_format` is installation-local and controls rendered dates in note rows, property chips/cells, note info, table-of-contents metadata, and search result subtitles; `AppPreferencesProvider` owns the UI-level value so rendering surfaces can consume it without prop forwarding, while date picker text input remains ISO for predictable manual entry and storage. `note_width_mode` is the installation-local default for rich-editor note width; individual notes can override it with `_width` when they already have frontmatter. `sidebar_type_pluralization_enabled` is installation-local and defaults to `true`; when false, type rows use exact type names unless the type document defines an explicit `sidebar_label` override. `ai_features_enabled` is installation-local and defaults to `true`; when false, Tolaria hides AI panel controls, status bar AI indicators, command-palette AI mode, and missing-agent prompts while leaving Settings as the re-enable path. `git_enabled` is also installation-local and defaults to `true`; when false, Tolaria hides Git status-bar entries and command-palette actions, disables AutoGit controls, and avoids background Git refresh/sync work while leaving Settings as the re-enable path. `default_ai_agent` remains the legacy installation-local CLI fallback. `default_ai_target` is the active AI target used by the AI panel and status bar; it can point at a coding agent or a configured direct model. `ai_model_providers` stores non-secret provider metadata for local/API model targets, while hosted API keys live in Tolaria's local app-data secrets file or user-managed environment variables instead of being persisted in app settings. Provider defaults and local/API grouping come from the shared `src/shared/aiModelProviderCatalog.json` catalog used by both renderer settings and the Tauri direct-model runtime. `hide_gitignored_files` is also installation-local and defaults to `true`; changing it reloads entries, search, saved views, and folders without restarting. The `all_notes_show_pdfs`, `all_notes_show_images`, and `all_notes_show_unsupported` flags are installation-local All Notes category toggles that default off and update the list/counts without changing vault files. The AutoGit fields are also installation-local: `useAutoGit` consumes them to schedule automatic checkpoints, while `useCommitFlow` and the status bar quick action reuse the same checkpoint runner and deterministic automatic commit message generation. ## Telemetry diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 65327453..200e4622 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -26,7 +26,9 @@ When deciding where to persist a piece of data, ask: **"Would the user want this | Property display order | Window size / position | | Per-note `_width` rich-editor width override | Default rich-editor note width | | Vault-authored `.gitignore` patterns | Whether this installation hides Gitignored files | +| N/A | Whether this installation shows Git features | | Per-vault All Notes note-list column overrides | All Notes PDF/image/unsupported file visibility | +| N/A | Per-vault Git setup prompt opt-out | | Type `_sidebar_label` overrides | Whether this installation auto-pluralizes type labels | | N/A | Registered workspace labels, aliases, mount state, and default new-note destination | | Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting | @@ -512,6 +514,7 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri - `inbox.noteListProperties`: Optional Inbox-only property chip override for the note list - `allNotes.noteListProperties`: Optional All Notes-only property chip override for the note list - `inbox.explicitOrganization`: When `false`, hide Inbox and the organized toggle so the vault behaves like a plain note collection +- `git_setup_preference`: `"never"` when the user has opted out of future automatic Git setup prompts for that vault ### Getting Started Vault @@ -522,7 +525,7 @@ On first launch, `useOnboarding` checks if the default vault exists. If not, it If the selected vault disappears after startup, `useVaultLoader` re-checks `check_vault_exists` when reloads or vault-derived surfaces fail. A confirmed missing path clears cached entries, folders, views, modified-file state, and prefetched note content, then `App` reuses the `vault-missing` `WelcomeScreen` state so note and view actions cannot keep targeting the stale active vault. -When an opened folder is not yet a git repo, Tolaria shows a dismissible Git setup dialog and a persistent `Git disabled` status-bar warning. Markdown scanning, note browsing, note editing, and search continue normally. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git from the dialog, the status-bar warning, or the `Initialize Git for Current Vault` command-palette action. +When an opened folder is not yet a git repo, Tolaria can show a Git setup dialog with Initialize, Not now, and Never for this vault actions. The Never choice stores a local per-vault `git_setup_preference` so the automatic dialog does not return for that vault, while manual initialization remains reachable from Git commands when global Git features are enabled. Markdown scanning, note browsing, note editing, and search continue normally in plain folders. Git-dependent surfaces (history, changes, commit, sync, conflict resolution, remotes, AutoGit, and auto-sync) stay unavailable until the user explicitly initializes Git. When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. Before app-managed setup, remote-connection, manual/automatic, and conflict-resolution commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria ` when the vault has no local Git identity yet. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures. @@ -657,7 +660,9 @@ flowchart TD `useGitRemoteStatus` re-checks `git_remote_status` for the default repository, and `useCommitFlow` can resolve remote status for an explicit selected repository when the commit dialog opens and again right before submit. If `hasRemote` is false, Tolaria keeps that repository's flow local-only: the status bar shows a neutral `No remote` chip for the default repository, the dialog copy switches from "Commit & Push" to "Commit", and no `git_push` call is attempted. -If the current vault is not a Git repository, Tolaria treats Git as disabled instead of degraded. The status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup. Command registration follows the same state: only `Initialize Git for Current Vault` is available in the Git group, while pull, commit, changes, conflict, and remote commands are hidden. `useAutoSync` is disabled for non-git vaults so the app does not run background Git commands against plain folders. +If the current vault is not a Git repository, Tolaria treats Git as unavailable instead of degraded. With global Git features enabled, the status bar replaces changes, commit, sync, remote, conflict, and history controls with a `Git disabled` warning that reopens Git setup unless the user has chosen not to be prompted automatically for that vault. Command registration follows the same state: only `Initialize Git for Current Vault` is available in the Git group, while pull, commit, changes, conflict, and remote commands are hidden. `useAutoSync` is disabled for non-git vaults so the app does not run background Git commands against plain folders. + +The installation-local `git_enabled` setting is a broader visibility switch. When it is `false`, Tolaria hides Git status-bar entries and Git command-palette actions completely, disables AutoGit controls in Settings, and prevents background Git refresh/sync work even for repositories that are otherwise Git-backed. Settings remains the re-enable path. The same local-only state enables the explicit Add Remote flow. `AddRemoteModal` is reachable from the `No remote` chip and the command palette. The backend `git_add_remote` command ensures the local author identity, adds `origin`, fetches it, refuses incompatible histories, and only enables tracking after a safe push or fast-forward-compatible check succeeds. @@ -865,8 +870,8 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks: | `useCommitFlow` | Commit dialog state, shared manual/automatic checkpoint runner | Git commit/push orchestration | | `useGitRemoteStatus` | `remoteStatus`, `refreshRemoteStatus()` | On-demand remote detection for commit UI | | `useUnifiedSearch` | Query, results, loading state | Keyword search | -| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, date display format, auto-sync interval, AutoGit thresholds, default AI agent, Gitignored-content visibility, All Notes file visibility) | Persistent settings | -| `useVaultConfig` | Per-vault UI preferences, AI permission mode | Vault-specific config | +| `useSettings` | App settings (telemetry, release channel, theme mode, UI language, date display format, auto-sync interval, Git visibility, AutoGit thresholds, default AI agent, Gitignored-content visibility, All Notes file visibility) | Persistent settings | +| `useVaultConfig` | Per-vault UI preferences, Git setup prompt preference, AI permission mode | Vault-specific config | | `appCommandDispatcher` | Manifest-backed shortcut/menu command IDs | Shared execution path for renderer and native menu commands | Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`. diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index edeb5e20..1795e89e 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -345,12 +345,12 @@ tolaria/ | File | Why it matters | |------|---------------| -| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, UI language, date display format, auto-sync interval, default note width, sidebar type pluralization, default AI agent). | +| `src/hooks/useSettings.ts` | App settings (telemetry, release channel, theme mode, UI language, date display format, Git visibility, auto-sync interval, default note width, sidebar type pluralization, default AI agent). | | `src/lib/releaseChannel.ts` | Normalizes persisted updater-channel values (`stable` default, optional `alpha`). | | `src/lib/appUpdater.ts` | Frontend wrapper for channel-aware updater commands. | | `src/hooks/useMainWindowSizeConstraints.ts` | Derives the main-window minimum width from the visible panes and asks Tauri to grow back to fit wider layouts. | -| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow, AI permission mode). | -| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, sync interval, UI language, content display preferences, default AI agent, and the vault-level explicit organization toggle. | +| `src/hooks/useVaultConfig.ts` | Per-vault local UI preferences (zoom, view mode, colors, Inbox columns, explicit organization workflow, Git setup prompt preference, AI permission mode). | +| `src/components/SettingsPanel.tsx` | Settings UI for telemetry, release channel, Git visibility, sync interval, UI language, content display preferences, default AI agent, and the vault-level explicit organization toggle. | | `src/hooks/useUpdater.ts` | In-app updates using the selected alpha/stable feed. | ## Architecture Patterns diff --git a/lara.lock b/lara.lock index 8ed1a470..7fef707d 100644 --- a/lara.lock +++ b/lara.lock @@ -141,9 +141,12 @@ files: settings.language.label: 244c7b77926f077b863c33ff1244e1ec settings.language.system: 41e2252344aa441e925589ddcb762e6d settings.language.summary: edc83f1f48ce29bc80bf2f2f90e24997 - settings.autogit.title: e59f720605ec1cfa42b3b97d89d7e883 + settings.autogit.title: 0bcc70105ad279503e31fe7b3f47b665 + settings.git.enable: c40503e1b0f483eecf3aff2fd01665c4 + settings.git.enableDescription: a96a43dc4dbf1490d665eef437807b07 settings.autogit.description.enabled: e65bec70ca4d7921be56f84d10836018 settings.autogit.description.disabled: 1143ef6ee00614816785e3c6fc299d51 + settings.autogit.description.gitDisabled: 55a1dd2d7f797062cde1374930221649 settings.autogit.enable: 9b205a4ae0e3ad4e6708155880ce9c75 settings.autogit.enableDescription: a7424a8f90c0b7f290a8144d12374554 settings.autogit.idleThreshold: 28016cc6fccd951a904ee3020cd733b6 diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b4f0aea0..b7259722 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -67,6 +67,7 @@ const SUPPORTED_UI_LANGUAGE_ALIASES: &[(&str, &str)] = &[ #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub struct Settings { pub auto_pull_interval_minutes: Option, + pub git_enabled: Option, pub autogit_enabled: Option, pub autogit_idle_threshold_seconds: Option, pub autogit_inactive_threshold_seconds: Option, @@ -177,6 +178,7 @@ pub fn normalize_ui_language(value: Option<&str>) -> Option { fn normalize_settings(settings: Settings) -> Settings { Settings { auto_pull_interval_minutes: settings.auto_pull_interval_minutes, + git_enabled: settings.git_enabled, autogit_enabled: settings.autogit_enabled, autogit_idle_threshold_seconds: normalize_optional_positive_u32( settings.autogit_idle_threshold_seconds, @@ -334,6 +336,7 @@ mod tests { fn test_settings_json_roundtrip() { let settings = Settings { auto_pull_interval_minutes: Some(10), + git_enabled: Some(false), autogit_enabled: Some(true), autogit_idle_threshold_seconds: Some(90), autogit_inactive_threshold_seconds: Some(30), @@ -376,6 +379,7 @@ mod tests { fn test_save_and_load_preserves_values() { let loaded = save_and_reload(Settings { auto_pull_interval_minutes: Some(10), + git_enabled: Some(false), autogit_enabled: Some(true), autogit_idle_threshold_seconds: Some(90), autogit_inactive_threshold_seconds: Some(30), @@ -397,6 +401,7 @@ mod tests { ..Default::default() }); assert_eq!(loaded.auto_pull_interval_minutes, Some(10)); + assert_eq!(loaded.git_enabled, Some(false)); assert_eq!(loaded.autogit_enabled, Some(true)); assert_eq!(loaded.autogit_idle_threshold_seconds, Some(90)); assert_eq!(loaded.autogit_inactive_threshold_seconds, Some(30)); diff --git a/src/App.tsx b/src/App.tsx index cda8af09..bb0d3fc1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -80,7 +80,7 @@ import { DeleteProgressNotice } from './components/DeleteProgressNotice' import { UpdateBanner } from './components/UpdateBanner' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from './mock-tauri' -import type { SidebarSelection, InboxPeriod, ModifiedFile, VaultEntry, ViewDefinition, ViewFile, WorkspaceIdentity } from './types' +import type { GitSetupPreference, SidebarSelection, InboxPeriod, ModifiedFile, VaultEntry, ViewDefinition, ViewFile, WorkspaceIdentity } from './types' import type { NoteListItem } from './utils/ai-context' import { initializeNoteProperties } from './utils/initializeNoteProperties' import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers' @@ -95,6 +95,7 @@ import type { NoteListMultiSelectionCommands } from './components/note-list/mult import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents' import { trackEvent } from './lib/telemetry' import { areAiFeaturesEnabled } from './lib/aiFeatures' +import { areGitFeaturesEnabled } from './lib/gitSettings' import { useAppCommandAiActions } from './hooks/useAppCommandAiActions' import { TOLARIA_DOCS_URL } from './constants/feedback' import { openExternalUrl } from './utils/url' @@ -355,13 +356,21 @@ function App() { vaultSwitcherLoaded: vaultSwitcher.loaded, windowMode: Boolean(noteWindowParams), }) + const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath) + const gitFeaturesEnabled = areGitFeaturesEnabled(settings) + const handleGitSetupPreferenceChange = useCallback((preference: GitSetupPreference) => { + updateConfig('git_setup_preference', preference) + }, [updateConfig]) const { dismissGitSetupDialog, gitRepoState, handleInitGitRepo, + neverForVaultGitSetupDialog, openGitSetupDialog, shouldShowGitSetupDialog, } = useGitSetupState({ + gitSetupPreference: vaultConfig.git_setup_preference, + onGitSetupPreferenceChange: handleGitSetupPreferenceChange, onToast: setToastMessage, resolvedPath, windowMode: Boolean(noteWindowParams), @@ -411,7 +420,6 @@ function App() { aiFeaturesEnabled ? resolvedPath : null, buildVaultAiGuidanceRefreshKey(vault.entries), ) - const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath) const explicitOrganizationEnabled = isExplicitOrganizationEnabled(vaultConfig.inbox?.explicitOrganization) const effectiveSelection = sanitizeSelectionForOrganization(selection, vaultConfig.inbox?.explicitOrganization) const isChangesSelection = effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'changes' @@ -479,18 +487,20 @@ function App() { [refreshRemoteStatusForRepository, resolvedPath], ) const refreshGitModifiedFiles = useCallback(async () => { + if (!gitFeaturesEnabled) return await Promise.all([ loadDefaultVaultModifiedFiles(), loadAllGitModifiedFiles({ includeStats: isChangesSelection }), ]) - }, [isChangesSelection, loadAllGitModifiedFiles, loadDefaultVaultModifiedFiles]) + }, [gitFeaturesEnabled, isChangesSelection, loadAllGitModifiedFiles, loadDefaultVaultModifiedFiles]) const loadVaultModifiedFiles = refreshGitModifiedFiles useEffect(() => { + if (!gitFeaturesEnabled) return if (gitRepoState !== 'ready') return void loadVaultModifiedFiles() void refreshGitRemoteStatus() - }, [gitRepoState, loadVaultModifiedFiles, refreshGitRemoteStatus]) + }, [gitFeaturesEnabled, gitRepoState, loadVaultModifiedFiles, refreshGitRemoteStatus]) const openMcpSetupDialog = useCallback(() => { setShowMcpSetupDialog(true) @@ -679,7 +689,7 @@ function App() { filterChangedPaths: filterExternalVaultPaths, }) const autoSync = useAutoSync({ - enabled: gitRepoState === 'ready', + enabled: gitFeaturesEnabled && gitRepoState === 'ready', vaultPath: resolvedPath, intervalMinutes: settings.auto_pull_interval_minutes, onVaultUpdated: handlePulledVaultUpdate, @@ -926,7 +936,7 @@ function App() { const selectedChangesModifiedFiles = gitSurfaces.changesModifiedFiles const commitModifiedFiles = gitSurfaces.commitModifiedFiles const changesRepositoryPath = gitSurfaces.changesRepositoryPath - const gitModifiedCount = allGitModifiedFiles.length + const gitModifiedCount = gitFeaturesEnabled ? allGitModifiedFiles.length : 0 const { activeDeletedFile, @@ -968,13 +978,13 @@ function App() { resolveRemoteStatusForVaultPath: refreshRemoteStatusForRepository, setToastMessage, onPushRejected: autoSync.handlePushRejected, - automaticVaultPaths: activeGitRepositoryPaths, + automaticVaultPaths: gitFeaturesEnabled ? activeGitRepositoryPaths : [], locale: appLocale, manualVaultPath: gitSurfaces.commitRepositoryPath, vaultPath: resolvedPath, }) const suggestedCommitMessage = useMemo(() => generateCommitMessage(commitModifiedFiles), [commitModifiedFiles]) - const isGitVault = gitRepoState !== 'missing' + const isGitVault = gitFeaturesEnabled && gitRepoState !== 'missing' const modifiedFilesSignature = useMemo( () => allGitModifiedFiles.map((file) => `${file.vaultPath ?? ''}:${file.relativePath}:${file.status}`).sort().join('|'), [allGitModifiedFiles], @@ -997,9 +1007,10 @@ function App() { const loadModifiedFiles = refreshGitModifiedFiles useEffect(() => { + if (!gitFeaturesEnabled) return if (!isChangesSelection) return void loadModifiedFilesForRepository(changesRepositoryPath, { includeStats: true }) - }, [changesRepositoryPath, isChangesSelection, loadModifiedFilesForRepository]) + }, [changesRepositoryPath, gitFeaturesEnabled, isChangesSelection, loadModifiedFilesForRepository]) useEffect(() => { if (modifiedFilesSignature.length === 0) return @@ -1007,12 +1018,13 @@ function App() { }, [modifiedFilesSignature, recordAutoGitActivity]) const handleCommitPush = useCallback(() => { + if (!gitFeaturesEnabled) return triggerCommitEntryAction({ autoGitEnabled: settings.autogit_enabled === true, openCommitDialog, runAutomaticCheckpoint, }) - }, [openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled]) + }, [gitFeaturesEnabled, openCommitDialog, runAutomaticCheckpoint, settings.autogit_enabled]) const handleTrackedContentChange = useCallback((path: string, content: string) => { recordAutoGitActivity() @@ -1527,6 +1539,7 @@ function App() { onDeleteNote: deleteActions.handleDeleteNote, onArchiveNote: entryActions.handleArchiveNote, onUnarchiveNote: entryActions.handleUnarchiveNote, onCommitPush: handleCommitPush, + gitFeaturesEnabled, isGitVault, onInitializeGit: openGitSetupDialog, onPull: autoSync.triggerSync, @@ -1759,8 +1772,8 @@ function App() { - handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} /> - + handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} commitActionPending={commitFlow.isOpeningCommitDialog} gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={openGitSetupDialog} isOffline={networkStatus.isOffline} isGitVault={isGitVault} isVaultReloading={vault.isReloading || isVaultContentLoading} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} themeMode={documentThemeMode} onZoomReset={zoom.zoomReset} onToggleThemeMode={settingsLoaded ? handleToggleThemeMode : undefined} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} onReorderVaults={vaultSwitcher.reorderVaults} onUpdateWorkspaceIdentity={vaultSwitcher.updateWorkspaceIdentity} aiFeaturesEnabled={aiFeaturesEnabled} mcpStatus={mcpStatus} onInstallMcp={openMcpSetupDialog} aiAgentsStatus={aiFeaturesEnabled ? aiAgentsStatus : undefined} vaultAiGuidanceStatus={aiFeaturesEnabled ? vaultAiGuidanceStatus : undefined} defaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.defaultAiAgent : undefined} defaultAiTarget={aiFeaturesEnabled ? settings.default_ai_target ?? undefined : undefined} aiModelProviders={aiFeaturesEnabled ? settings.ai_model_providers ?? [] : []} onSetDefaultAiAgent={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiAgent : undefined} onSetDefaultAiTarget={aiFeaturesEnabled ? aiAgentPreferences.setDefaultAiTarget : undefined} onRestoreVaultAiGuidance={aiFeaturesEnabled ? () => { void restoreVaultAiGuidance() } : undefined} locale={appLocale} /> + setToastMessage(null)} /> @@ -1809,7 +1822,7 @@ function App() { onCommit={conflictResolver.commitResolution} onClose={conflictFlow.handleCloseConflictResolver} /> - + diff --git a/src/components/GitRequiredModal.test.tsx b/src/components/GitRequiredModal.test.tsx index a8ef42a2..448953c9 100644 --- a/src/components/GitRequiredModal.test.tsx +++ b/src/components/GitRequiredModal.test.tsx @@ -13,6 +13,7 @@ describe('GitSetupDialog', () => { render() expect(screen.getByText('Initialize Git')).toBeInTheDocument() expect(screen.getByText('Not now')).toBeInTheDocument() + expect(screen.getByText('Never for this vault')).toBeInTheDocument() }) it('calls onInitGit when primary button clicked', async () => { @@ -29,6 +30,13 @@ describe('GitSetupDialog', () => { expect(onDismiss).toHaveBeenCalledOnce() }) + it('calls onNeverForVault when never button clicked', () => { + const onNeverForVault = vi.fn() + render() + fireEvent.click(screen.getByText('Never for this vault')) + expect(onNeverForVault).toHaveBeenCalledOnce() + }) + it('disables buttons and shows spinner while creating', async () => { let resolve: () => void const onInitGit = vi.fn().mockReturnValue(new Promise(r => { resolve = r })) diff --git a/src/components/GitRequiredModal.tsx b/src/components/GitRequiredModal.tsx index 1090d714..74e84128 100644 --- a/src/components/GitRequiredModal.tsx +++ b/src/components/GitRequiredModal.tsx @@ -14,9 +14,10 @@ interface GitSetupDialogProps { open: boolean onInitGit: () => Promise onDismiss: () => void + onNeverForVault?: () => void } -export function GitSetupDialog({ open, onInitGit, onDismiss }: GitSetupDialogProps) { +export function GitSetupDialog({ open, onInitGit, onDismiss, onNeverForVault }: GitSetupDialogProps) { const [creating, setCreating] = useState(false) const [error, setError] = useState(null) @@ -51,6 +52,9 @@ export function GitSetupDialog({ open, onInitGit, onDismiss }: GitSetupDialogPro

)} + diff --git a/src/components/GitSettingsSection.tsx b/src/components/GitSettingsSection.tsx new file mode 100644 index 00000000..1fc8432e --- /dev/null +++ b/src/components/GitSettingsSection.tsx @@ -0,0 +1,105 @@ +import type { createTranslator } from '../lib/i18n' +import { + NumberInputControl, + SectionHeading, + SettingsGroup, + SettingsRow, + SettingsSwitchRow, +} from './SettingsControls' + +type Translate = ReturnType + +interface GitSettingsSectionProps { + autoGitEnabled: boolean + autoGitIdleThresholdSeconds: number + autoGitInactiveThresholdSeconds: number + gitFeaturesEnabled: boolean + isGitVault: boolean + setAutoGitEnabled: (value: boolean) => void + setAutoGitIdleThresholdSeconds: (value: number) => void + setAutoGitInactiveThresholdSeconds: (value: number) => void + setGitFeaturesEnabled: (value: boolean) => void + t: Translate +} + +function describeAutoGitAvailability( + gitFeaturesEnabled: boolean, + isGitVault: boolean, + t: Translate, +): string { + if (!gitFeaturesEnabled) return t('settings.autogit.description.gitDisabled') + return isGitVault + ? t('settings.autogit.description.enabled') + : t('settings.autogit.description.disabled') +} + +export function GitSettingsSection(props: GitSettingsSectionProps) { + const { + autoGitEnabled, + autoGitIdleThresholdSeconds, + autoGitInactiveThresholdSeconds, + gitFeaturesEnabled, + isGitVault, + setAutoGitEnabled, + setAutoGitIdleThresholdSeconds, + setAutoGitInactiveThresholdSeconds, + setGitFeaturesEnabled, + t, + } = props + const gitControlsAvailable = gitFeaturesEnabled && isGitVault + + return ( + <> + + + + + + + + + + + + + + + + + ) +} diff --git a/src/components/SettingsPanel.test.tsx b/src/components/SettingsPanel.test.tsx index 79aa47a6..6b8df32d 100644 --- a/src/components/SettingsPanel.test.tsx +++ b/src/components/SettingsPanel.test.tsx @@ -16,6 +16,7 @@ vi.mock('../lib/telemetry', () => ({ const emptySettings: Settings = { auto_pull_interval_minutes: null, + git_enabled: null, autogit_enabled: null, autogit_idle_threshold_seconds: null, autogit_inactive_threshold_seconds: null, @@ -667,11 +668,42 @@ describe('SettingsPanel', () => { ) + expect(screen.getAllByText('Git')).not.toHaveLength(0) + expect(screen.getByRole('switch', { name: 'Enable Git features' })).toHaveAttribute('aria-checked', 'true') expect(screen.getByRole('switch', { name: 'Enable AutoGit' })).toHaveAttribute('aria-checked', 'false') expect(screen.getByTestId('settings-autogit-idle-threshold')).toHaveValue(90) expect(screen.getByTestId('settings-autogit-inactive-threshold')).toHaveValue(30) }) + it('saves the global Git feature preference when toggled off', () => { + render( + + ) + + fireEvent.click(screen.getByRole('switch', { name: 'Enable Git features' })) + fireEvent.click(screen.getByTestId('settings-save')) + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + git_enabled: false, + })) + }) + + it('disables AutoGit controls when Git features are disabled globally', () => { + render( + + ) + + expect(screen.getByRole('switch', { name: 'Enable Git features' })).toHaveAttribute('aria-checked', 'false') + expect(screen.getByRole('switch', { name: 'Enable AutoGit' })).toBeDisabled() + expect(screen.getByTestId('settings-autogit-idle-threshold')).toBeDisabled() + expect(screen.getByTestId('settings-autogit-inactive-threshold')).toBeDisabled() + }) + it('saves AutoGit preferences when toggled and edited', () => { render( diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 451fae17..6a9aeb1f 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -43,13 +43,14 @@ import { } from '../lib/themeMode' import { normalizeReleaseChannel, serializeReleaseChannel, type ReleaseChannel } from '../lib/releaseChannel' import { shouldHideGitignoredFiles } from '../lib/gitignoredVisibility' +import { areGitFeaturesEnabled } from '../lib/gitSettings' import { areAiFeaturesEnabled } from '../lib/aiFeatures' import { trackAllNotesVisibilityChanged } from '../lib/productAnalytics' import { AiProviderSettings } from './AiProviderSettings' +import { GitSettingsSection } from './GitSettingsSection' import { PrivacySettingsSection } from './PrivacySettingsSection' import { SettingsBodyNav } from './SettingsBodyNav' import { - NumberInputControl, SectionHeading, SelectControl, SettingsGroup, @@ -102,6 +103,7 @@ interface SettingsPanelProps { interface SettingsDraft { pullInterval: number + gitFeaturesEnabled: boolean autoGitEnabled: boolean autoGitIdleThresholdSeconds: number autoGitInactiveThresholdSeconds: number @@ -129,6 +131,8 @@ interface SettingsBodyProps { t: Translate pullInterval: number setPullInterval: (value: number) => void + gitFeaturesEnabled: boolean + setGitFeaturesEnabled: (value: boolean) => void isGitVault: boolean autoGitEnabled: boolean setAutoGitEnabled: (value: boolean) => void @@ -196,6 +200,7 @@ function createSettingsDraft( ): SettingsDraft { return { pullInterval: settings.auto_pull_interval_minutes ?? 5, + gitFeaturesEnabled: areGitFeaturesEnabled(settings), autoGitEnabled: settings.autogit_enabled ?? false, autoGitIdleThresholdSeconds: sanitizePositiveInteger( settings.autogit_idle_threshold_seconds, @@ -248,6 +253,7 @@ function resolveAnonymousId(settings: Settings, draft: SettingsDraft): string | function buildSettingsFromDraft(settings: Settings, draft: SettingsDraft): Settings { const nextSettings = { auto_pull_interval_minutes: draft.pullInterval, + git_enabled: draft.gitFeaturesEnabled, autogit_enabled: draft.autoGitEnabled, autogit_idle_threshold_seconds: draft.autoGitIdleThresholdSeconds, autogit_inactive_threshold_seconds: draft.autoGitInactiveThresholdSeconds, @@ -515,6 +521,8 @@ function SettingsBodyFromDraft({ systemLocale={systemLocale} pullInterval={draft.pullInterval} setPullInterval={(value) => updateDraft('pullInterval', value)} + gitFeaturesEnabled={draft.gitFeaturesEnabled} + setGitFeaturesEnabled={(value) => updateDraft('gitFeaturesEnabled', value)} isGitVault={isGitVault} autoGitEnabled={draft.autoGitEnabled} setAutoGitEnabled={(value) => updateDraft('autoGitEnabled', value)} @@ -586,6 +594,8 @@ function SettingsSyncAndAppearanceSections({ systemLocale, pullInterval, setPullInterval, + gitFeaturesEnabled, + setGitFeaturesEnabled, isGitVault, autoGitEnabled, setAutoGitEnabled, @@ -631,8 +641,10 @@ function SettingsSyncAndAppearanceSections({ /> - ) { - return ( - <> - - - - - - - - - - - - - - - ) -} - function buildLanguageOptions(t: Translate, locale: AppLocale, systemLocale: AppLocale) { return [ { diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index caef6321..93b40058 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -782,6 +782,28 @@ describe('StatusBar', () => { expect(screen.queryByTestId('status-commit-push')).not.toBeInTheDocument() }) + it('hides all git controls when Git features are disabled globally', () => { + render( + + ) + + expect(screen.queryByTestId('status-missing-git')).not.toBeInTheDocument() + expect(screen.queryByTestId('status-pulse')).not.toBeInTheDocument() + expect(screen.queryByTestId('status-commit-push')).not.toBeInTheDocument() + expect(screen.queryByTestId('status-changes')).not.toBeInTheDocument() + }) + it('opens Git setup from the missing-Git warning with mouse and keyboard', () => { const onInitializeGit = vi.fn() render( diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index d4e261cf..bc2174a2 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -75,6 +75,7 @@ interface StatusBarProps { onClickPulse?: () => void onCommitPush?: () => void commitActionPending?: boolean + gitFeaturesEnabled?: boolean onInitializeGit?: () => void isOffline?: boolean isVaultReloading?: boolean @@ -135,6 +136,7 @@ function StatusBarPrimaryFromFooter({ onClickPulse, onCommitPush, commitActionPending = false, + gitFeaturesEnabled = true, onInitializeGit, isOffline = false, isVaultReloading = false, @@ -186,6 +188,7 @@ function StatusBarPrimaryFromFooter({ onClickPulse={onClickPulse} onCommitPush={onCommitPush} commitActionPending={commitActionPending} + gitFeaturesEnabled={gitFeaturesEnabled} onInitializeGit={onInitializeGit} isOffline={isOffline} isVaultReloading={isVaultReloading} diff --git a/src/components/settingsPreferenceTracking.ts b/src/components/settingsPreferenceTracking.ts index 2167779f..834c4f58 100644 --- a/src/components/settingsPreferenceTracking.ts +++ b/src/components/settingsPreferenceTracking.ts @@ -4,9 +4,11 @@ import { trackAiFeaturesEnabledChanged, trackDateDisplayFormatChanged, trackDefaultNoteWidthChanged, + trackGitFeaturesEnabledChanged, trackSidebarTypePluralizationChanged, } from '../lib/productAnalytics' import { areAiFeaturesEnabled } from '../lib/aiFeatures' +import { areGitFeaturesEnabled } from '../lib/gitSettings' import { DEFAULT_DATE_DISPLAY_FORMAT, normalizeDateDisplayFormat, @@ -19,6 +21,7 @@ export interface SettingsPreferenceDraft { aiFeaturesEnabled: boolean dateDisplayFormat: DateDisplayFormat defaultNoteWidth: NoteWidthMode + gitFeaturesEnabled: boolean multiWorkspaceEnabled: boolean sidebarTypePluralizationEnabled: boolean } @@ -34,6 +37,11 @@ export function trackSettingsPreferenceChanges(settings: Settings, draft: Settin trackAiFeaturesEnabledChanged(draft.aiFeaturesEnabled) } + const previousGitFeaturesEnabled = areGitFeaturesEnabled(settings) + if (previousGitFeaturesEnabled !== draft.gitFeaturesEnabled) { + trackGitFeaturesEnabledChanged(draft.gitFeaturesEnabled) + } + const previousDateDisplayFormat = normalizeDateDisplayFormat(settings.date_display_format) ?? DEFAULT_DATE_DISPLAY_FORMAT if (previousDateDisplayFormat !== draft.dateDisplayFormat) { trackDateDisplayFormatChanged(draft.dateDisplayFormat) diff --git a/src/components/status-bar/StatusBarSections.tsx b/src/components/status-bar/StatusBarSections.tsx index 0ed39b76..738e918a 100644 --- a/src/components/status-bar/StatusBarSections.tsx +++ b/src/components/status-bar/StatusBarSections.tsx @@ -57,6 +57,7 @@ interface StatusBarPrimarySectionProps { onClickPulse?: () => void onCommitPush?: () => void commitActionPending?: boolean + gitFeaturesEnabled?: boolean onInitializeGit?: () => void isOffline?: boolean isVaultReloading?: boolean @@ -197,6 +198,7 @@ function StatusBarPrimaryBadges({ onClickPending, onCommitPush, commitActionPending, + gitFeaturesEnabled, onInitializeGit, syncStatus, lastSyncTime, @@ -229,6 +231,7 @@ function StatusBarPrimaryBadges({ onClickPending?: () => void onCommitPush?: () => void commitActionPending?: boolean + gitFeaturesEnabled: boolean onInitializeGit?: () => void syncStatus: SyncStatus lastSyncTime: number | null @@ -259,7 +262,7 @@ function StatusBarPrimaryBadges({ <> - {isGitVault ? ( + {gitFeaturesEnabled && isGitVault ? ( <> @@ -277,9 +280,9 @@ function StatusBarPrimaryBadges({ - ) : ( + ) : gitFeaturesEnabled ? ( - )} + ) : null} {mcpStatus && } void onCommitPush: () => void @@ -17,6 +18,7 @@ export function buildGitCommands(config: GitCommandsConfig): CommandAction[] { const { modifiedCount, canAddRemote, + gitFeaturesEnabled = true, isGitVault = true, onAddRemote, onCommitPush, @@ -26,6 +28,8 @@ export function buildGitCommands(config: GitCommandsConfig): CommandAction[] { onSelect, } = config + if (!gitFeaturesEnabled) return [] + if (!isGitVault) { return [ { diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts index 881a0352..cef285d6 100644 --- a/src/hooks/useAppCommands.ts +++ b/src/hooks/useAppCommands.ts @@ -71,6 +71,7 @@ interface AppCommandsConfig { onCreateEmptyVault?: () => void onAddRemote?: () => void canAddRemote?: boolean + gitFeaturesEnabled?: boolean isGitVault?: boolean onInitializeGit?: () => void onCreateType?: () => void @@ -186,6 +187,7 @@ type CommandRegistryVaultActions = Pick< | 'onCreateEmptyVault' | 'onAddRemote' | 'canAddRemote' + | 'gitFeaturesEnabled' | 'isGitVault' | 'onInitializeGit' | 'onCheckForUpdates' @@ -479,6 +481,7 @@ function createCommandRegistryVaultConfig( onCreateEmptyVault: config.onCreateEmptyVault, onAddRemote: config.onAddRemote ?? requestAddRemote, canAddRemote: config.canAddRemote ?? true, + gitFeaturesEnabled: config.gitFeaturesEnabled, isGitVault: config.isGitVault, onInitializeGit: config.onInitializeGit, onCheckForUpdates: config.onCheckForUpdates, diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index 4bf8aa2b..c1e65539 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -140,6 +140,17 @@ describe('useCommandRegistry', () => { expect(findCommand(result.current, 'view-changes')).toBeUndefined() }) + it('hides all Git commands when Git features are disabled globally', () => { + const config = makeConfig({ gitFeaturesEnabled: false, isGitVault: false, modifiedCount: 5 }) + const { result } = renderHook(() => useCommandRegistry(config)) + + expect(findCommand(result.current, 'initialize-git')).toBeUndefined() + expect(findCommand(result.current, 'commit-push')).toBeUndefined() + expect(findCommand(result.current, 'git-pull')).toBeUndefined() + expect(findCommand(result.current, 'add-remote')).toBeUndefined() + expect(findCommand(result.current, 'view-changes')).toBeUndefined() + }) + it('resolve-conflicts stays enabled across rerenders', () => { const config = makeConfig() const { result, rerender } = renderHook( diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts index 4c490c79..f3c1958a 100644 --- a/src/hooks/useCommandRegistry.ts +++ b/src/hooks/useCommandRegistry.ts @@ -74,6 +74,7 @@ interface CommandRegistryConfig { onCreateEmptyVault?: () => void onAddRemote?: () => void canAddRemote?: boolean + gitFeaturesEnabled?: boolean isGitVault?: boolean onInitializeGit?: () => void onCreateType?: () => void @@ -150,7 +151,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onCustomizeNoteListColumns, canCustomizeNoteListColumns, onRestoreDeletedNote, canRestoreDeletedNote, selection, noteListFilter, onSetNoteListFilter, - isGitVault, onInitializeGit, + gitFeaturesEnabled, isGitVault, onInitializeGit, } = config const hasActiveNote = activeTabPath !== null @@ -212,6 +213,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com const gitCommands = useMemo(() => buildGitCommands({ modifiedCount, + gitFeaturesEnabled, isGitVault, canAddRemote: config.canAddRemote ?? false, onAddRemote: config.onAddRemote, @@ -221,7 +223,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com onResolveConflicts, onSelect, }), [ - modifiedCount, isGitVault, config.canAddRemote, config.onAddRemote, + modifiedCount, gitFeaturesEnabled, isGitVault, config.canAddRemote, config.onAddRemote, onCommitPush, onInitializeGit, onPull, onResolveConflicts, onSelect, ]) diff --git a/src/hooks/useGitSetupState.test.tsx b/src/hooks/useGitSetupState.test.tsx new file mode 100644 index 00000000..71b47f33 --- /dev/null +++ b/src/hooks/useGitSetupState.test.tsx @@ -0,0 +1,74 @@ +import { renderHook, act, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useGitSetupState, type GitSetupPreference } from './useGitSetupState' + +const mockInvokeFn = vi.fn() + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})) + +vi.mock('../mock-tauri', () => ({ + isTauri: () => false, + mockInvoke: (cmd: string, args?: Record) => mockInvokeFn(cmd, args), +})) + +function renderGitSetupState( + preference: GitSetupPreference = 'prompt', + onGitSetupPreferenceChange = vi.fn(), +) { + return renderHook(() => useGitSetupState({ + gitSetupPreference: preference, + onGitSetupPreferenceChange, + onToast: vi.fn(), + resolvedPath: '/vault', + windowMode: false, + })) +} + +describe('useGitSetupState', () => { + beforeEach(() => { + vi.clearAllMocks() + mockInvokeFn.mockImplementation((cmd: string) => { + if (cmd === 'is_git_repo') return Promise.resolve(false) + if (cmd === 'init_git_repo') return Promise.resolve(null) + return Promise.resolve(null) + }) + }) + + it('does not auto-open the Git setup dialog after never is saved for the vault', async () => { + const { result } = renderGitSetupState('never') + + await waitFor(() => { + expect(result.current.gitRepoState).toBe('missing') + }) + + expect(result.current.shouldShowGitSetupDialog).toBe(false) + }) + + it('still allows the Git setup dialog to be opened manually after never is saved', async () => { + const { result } = renderGitSetupState('never') + + await waitFor(() => { + expect(result.current.gitRepoState).toBe('missing') + }) + + act(() => result.current.openGitSetupDialog()) + + expect(result.current.shouldShowGitSetupDialog).toBe(true) + }) + + it('persists never for this vault and closes the dialog', async () => { + const onGitSetupPreferenceChange = vi.fn() + const { result } = renderGitSetupState('prompt', onGitSetupPreferenceChange) + + await waitFor(() => { + expect(result.current.shouldShowGitSetupDialog).toBe(true) + }) + + act(() => result.current.neverForVaultGitSetupDialog()) + + expect(onGitSetupPreferenceChange).toHaveBeenCalledWith('never') + expect(result.current.shouldShowGitSetupDialog).toBe(false) + }) +}) diff --git a/src/hooks/useGitSetupState.ts b/src/hooks/useGitSetupState.ts index 63a77755..619a7742 100644 --- a/src/hooks/useGitSetupState.ts +++ b/src/hooks/useGitSetupState.ts @@ -1,34 +1,38 @@ import { useCallback, useEffect, useState } from 'react' import { invoke } from '@tauri-apps/api/core' import { isTauri, mockInvoke } from '../mock-tauri' +import type { GitSetupPreference } from '../types' + +export type { GitSetupPreference } from '../types' export type GitRepoState = 'checking' | 'missing' | 'ready' +type GitRepoStatus = { path: string; state: GitRepoState } interface GitSetupStateConfig { + gitSetupPreference?: GitSetupPreference | null + onGitSetupPreferenceChange?: (preference: GitSetupPreference) => void onToast: (message: string | null) => void resolvedPath: string windowMode: boolean } -export function useGitSetupState({ - onToast, - resolvedPath, - windowMode, -}: GitSetupStateConfig) { - const [gitRepoStatus, setGitRepoStatus] = useState<{ path: string; state: GitRepoState }>({ +function checkGitRepo(resolvedPath: string): Promise { + return isTauri() + ? invoke('is_git_repo', { vaultPath: resolvedPath }) + : mockInvoke('is_git_repo', { vaultPath: resolvedPath }) +} + +function useCheckedGitRepoState(resolvedPath: string) { + const [gitRepoStatus, setGitRepoStatus] = useState({ path: '', state: 'checking', }) - const [dismissedGitSetupPath, setDismissedGitSetupPath] = useState(null) const gitRepoState = gitRepoStatus.path === resolvedPath ? gitRepoStatus.state : 'checking' useEffect(() => { if (!resolvedPath) return let cancelled = false - const check = isTauri() - ? invoke('is_git_repo', { vaultPath: resolvedPath }) - : mockInvoke('is_git_repo', { vaultPath: resolvedPath }) - check + checkGitRepo(resolvedPath) .then(isGit => { if (!cancelled) setGitRepoStatus({ path: resolvedPath, state: isGit ? 'ready' : 'missing' }) }) @@ -40,34 +44,88 @@ export function useGitSetupState({ } }, [resolvedPath]) + const markGitRepoReady = useCallback(() => { + setGitRepoStatus({ path: resolvedPath, state: 'ready' }) + }, [resolvedPath]) + + return { gitRepoState, markGitRepoReady } +} + +function shouldShowGitSetupDialog({ + dismissedGitSetupPath, + gitRepoState, + gitSetupPreference, + manuallyOpened, + resolvedPath, + windowMode, +}: { + dismissedGitSetupPath: string | null + gitRepoState: GitRepoState + gitSetupPreference: GitSetupPreference | null | undefined + manuallyOpened: boolean + resolvedPath: string + windowMode: boolean +}): boolean { + if (windowMode || gitRepoState !== 'missing') return false + if (manuallyOpened) return true + return gitSetupPreference !== 'never' && dismissedGitSetupPath !== resolvedPath +} + +export function useGitSetupState({ + gitSetupPreference = 'prompt', + onGitSetupPreferenceChange, + onToast, + resolvedPath, + windowMode, +}: GitSetupStateConfig) { + const [dismissedGitSetupPath, setDismissedGitSetupPath] = useState(null) + const [manuallyOpened, setManuallyOpened] = useState(false) + const { gitRepoState, markGitRepoReady } = useCheckedGitRepoState(resolvedPath) + const openGitSetupDialog = useCallback(() => { if (gitRepoState !== 'missing') return + setManuallyOpened(true) setDismissedGitSetupPath(null) }, [gitRepoState]) const dismissGitSetupDialog = useCallback(() => { + setManuallyOpened(false) setDismissedGitSetupPath(resolvedPath) }, [resolvedPath]) + const neverForVaultGitSetupDialog = useCallback(() => { + onGitSetupPreferenceChange?.('never') + setManuallyOpened(false) + setDismissedGitSetupPath(resolvedPath) + }, [onGitSetupPreferenceChange, resolvedPath]) + const handleInitGitRepo = useCallback(async () => { if (isTauri()) { await invoke('init_git_repo', { vaultPath: resolvedPath }) } else { await mockInvoke('init_git_repo', { vaultPath: resolvedPath }) } - setGitRepoStatus({ path: resolvedPath, state: 'ready' }) + markGitRepoReady() + onGitSetupPreferenceChange?.('prompt') + setManuallyOpened(false) setDismissedGitSetupPath(null) onToast('Git initialized for this vault') - }, [onToast, resolvedPath]) + }, [markGitRepoReady, onGitSetupPreferenceChange, onToast, resolvedPath]) - const showGitSetupDialog = !windowMode - && gitRepoState === 'missing' - && dismissedGitSetupPath !== resolvedPath + const showGitSetupDialog = shouldShowGitSetupDialog({ + dismissedGitSetupPath, + gitRepoState, + gitSetupPreference, + manuallyOpened, + resolvedPath, + windowMode, + }) return { dismissGitSetupDialog, gitRepoState, handleInitGitRepo, + neverForVaultGitSetupDialog, openGitSetupDialog, showGitSetupDialog, shouldShowGitSetupDialog: showGitSetupDialog, diff --git a/src/hooks/useSettings.test.ts b/src/hooks/useSettings.test.ts index 5e552ecd..0e551247 100644 --- a/src/hooks/useSettings.test.ts +++ b/src/hooks/useSettings.test.ts @@ -14,6 +14,7 @@ const { trackEventMock } = vi.hoisted(() => ({ const defaultSettings: Settings = { auto_pull_interval_minutes: null, + git_enabled: null, autogit_enabled: null, autogit_idle_threshold_seconds: null, autogit_inactive_threshold_seconds: null, @@ -41,6 +42,7 @@ const defaultSettings: Settings = { const savedSettings: Settings = { auto_pull_interval_minutes: 15, + git_enabled: null, autogit_enabled: true, autogit_idle_threshold_seconds: 90, autogit_inactive_threshold_seconds: 30, @@ -105,6 +107,7 @@ async function renderLoadedSettings(): Promise { function changedSettings(): Settings { return { auto_pull_interval_minutes: null, + git_enabled: null, autogit_enabled: false, autogit_idle_threshold_seconds: 120, autogit_inactive_threshold_seconds: 45, diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index eb589414..26ec1e55 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -36,6 +36,7 @@ async function tauriCall(command: string, tauriArgs: Record, const EMPTY_SETTINGS: Settings = { auto_pull_interval_minutes: null, + git_enabled: null, autogit_enabled: null, autogit_idle_threshold_seconds: null, autogit_inactive_threshold_seconds: null, @@ -66,6 +67,7 @@ function normalizeSettings(settings: Settings): Settings { return { ...settings, + git_enabled: settings.git_enabled ?? null, release_channel: serializeReleaseChannel( normalizeReleaseChannel(settings.release_channel), ), diff --git a/src/hooks/useVaultConfig.ts b/src/hooks/useVaultConfig.ts index 281bf924..d2728e2a 100644 --- a/src/hooks/useVaultConfig.ts +++ b/src/hooks/useVaultConfig.ts @@ -22,6 +22,7 @@ function storageKey(vaultPath: string): string { function loadFromStorage(vaultPath: string): VaultConfig { const DEFAULT: VaultConfig = { zoom: null, view_mode: null, editor_mode: null, note_layout: null, + git_setup_preference: 'prompt', ai_agent_permission_mode: DEFAULT_AI_AGENT_PERMISSION_MODE, tag_colors: null, status_colors: null, property_display_modes: null, inbox: null, allNotes: null, diff --git a/src/lib/gitSettings.ts b/src/lib/gitSettings.ts new file mode 100644 index 00000000..604d0c99 --- /dev/null +++ b/src/lib/gitSettings.ts @@ -0,0 +1,5 @@ +import type { Settings } from '../types' + +export function areGitFeaturesEnabled(settings: Pick): boolean { + return settings.git_enabled !== false +} diff --git a/src/lib/locales/de-DE.json b/src/lib/locales/de-DE.json index 5cbf82c6..d8b398fb 100644 --- a/src/lib/locales/de-DE.json +++ b/src/lib/locales/de-DE.json @@ -139,9 +139,12 @@ "settings.language.label": "Anzeigesprache", "settings.language.system": "System ({language})", "settings.language.summary": "Fehlende Übersetzungen fallen auf Englisch zurück, sodass teilweise übersetzte Locales weiterhin nutzbar bleiben.", - "settings.autogit.title": "AutoGit", + "settings.autogit.title": "Git", + "settings.git.enable": "Git-Funktionen aktivieren", + "settings.git.enableDescription": "Zeige Git-Status, -Verlauf, -Änderungen, -Commits, -Synchronisierung und AutoGit-Steuerelemente in ganz Tolaria an.", "settings.autogit.description.enabled": "Erstellt nach Bearbeitungspausen oder wenn die App nicht mehr aktiv ist automatisch konservative Git-Checkpoints.", "settings.autogit.description.disabled": "AutoGit ist erst verfügbar, wenn der aktuelle Vault Git-fähig ist. Initialisieren Sie zunächst Git für diesen Vault.", + "settings.autogit.description.gitDisabled": "AutoGit ist nicht verfügbar, wenn die Git-Funktionen deaktiviert sind.", "settings.autogit.enable": "AutoGit aktivieren", "settings.autogit.enableDescription": "Wenn diese Option aktiviert ist, führt Tolaria nach einer Inaktivitätspause oder nachdem die App inaktiv geworden ist, automatisch ein Commit und einen Push der gespeicherten lokalen Änderungen durch.", "settings.autogit.idleThreshold": "Leerlaufschwelle (Sekunden)", diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index 8b60866e..91ecb1b5 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -139,9 +139,12 @@ "settings.language.label": "Display language", "settings.language.system": "System ({language})", "settings.language.summary": "Missing translations fall back to English so partially translated locales stay usable.", - "settings.autogit.title": "AutoGit", + "settings.autogit.title": "Git", + "settings.git.enable": "Enable Git features", + "settings.git.enableDescription": "Show Git status, history, changes, commits, sync, and AutoGit controls throughout Tolaria.", "settings.autogit.description.enabled": "Automatically create conservative Git checkpoints after editing pauses or when the app is no longer active.", "settings.autogit.description.disabled": "AutoGit is unavailable until the current vault is Git-enabled. Initialize Git for this vault first.", + "settings.autogit.description.gitDisabled": "AutoGit is unavailable while Git features are disabled.", "settings.autogit.enable": "Enable AutoGit", "settings.autogit.enableDescription": "When enabled, Tolaria will commit and push saved local changes automatically after an idle pause or after the app becomes inactive.", "settings.autogit.idleThreshold": "Idle threshold (seconds)", diff --git a/src/lib/locales/es-419.json b/src/lib/locales/es-419.json index 93b21b9c..fadcfc7c 100644 --- a/src/lib/locales/es-419.json +++ b/src/lib/locales/es-419.json @@ -139,9 +139,12 @@ "settings.language.label": "Idioma de la interfaz", "settings.language.system": "Sistema ({language})", "settings.language.summary": "Cuando faltan traducciones, se utiliza el inglés como idioma predeterminado para que las localizaciones parcialmente traducidas sigan siendo utilizables.", - "settings.autogit.title": "AutoGit", + "settings.autogit.title": "Git", + "settings.git.enable": "Habilitar las funciones de Git", + "settings.git.enableDescription": "Muestra el estado de Git, el historial, los cambios, las confirmaciones, la sincronización y los controles de AutoGit en toda Tolaria.", "settings.autogit.description.enabled": "Crea automáticamente puntos de control Git conservadores después de pausas en la edición o cuando la aplicación deje de estar activa.", "settings.autogit.description.disabled": "AutoGit no está disponible hasta que el almacén actual esté habilitado para Git. Primero, inicialice Git para este almacén.", + "settings.autogit.description.gitDisabled": "AutoGit no está disponible mientras las funciones de Git están deshabilitadas.", "settings.autogit.enable": "Habilitar AutoGit", "settings.autogit.enableDescription": "Cuando esté habilitado, Tolaria confirmará y enviará automáticamente los cambios locales guardados después de una pausa por inactividad o cuando la aplicación deje de estar activa.", "settings.autogit.idleThreshold": "Umbral de inactividad (segundos)", diff --git a/src/lib/locales/es-ES.json b/src/lib/locales/es-ES.json index 08bb5864..c34a3dd3 100644 --- a/src/lib/locales/es-ES.json +++ b/src/lib/locales/es-ES.json @@ -139,9 +139,12 @@ "settings.language.label": "Idioma de la interfaz", "settings.language.system": "Sistema ({language})", "settings.language.summary": "Cuando faltan traducciones, se utiliza el inglés como idioma predeterminado para que las localizaciones parcialmente traducidas sigan siendo utilizables.", - "settings.autogit.title": "AutoGit", + "settings.autogit.title": "Git", + "settings.git.enable": "Habilitar las funciones de Git", + "settings.git.enableDescription": "Muestra el estado de Git, el historial, los cambios, las confirmaciones, la sincronización y los controles de AutoGit en todo Tolaria.", "settings.autogit.description.enabled": "Crea automáticamente puntos de control Git conservadores después de pausas en la edición o cuando la aplicación deje de estar activa.", "settings.autogit.description.disabled": "AutoGit no está disponible hasta que el almacén actual esté habilitado para Git. Primero, inicialice Git para este almacén.", + "settings.autogit.description.gitDisabled": "AutoGit no está disponible mientras las funciones de Git están deshabilitadas.", "settings.autogit.enable": "Habilitar AutoGit", "settings.autogit.enableDescription": "Cuando esté habilitado, Tolaria confirmará y enviará automáticamente los cambios locales guardados después de una pausa por inactividad o cuando la aplicación deje de estar activa.", "settings.autogit.idleThreshold": "Umbral de inactividad (segundos)", diff --git a/src/lib/locales/fr-FR.json b/src/lib/locales/fr-FR.json index 03da9d52..a61f147c 100644 --- a/src/lib/locales/fr-FR.json +++ b/src/lib/locales/fr-FR.json @@ -139,9 +139,12 @@ "settings.language.label": "Langue d'affichage", "settings.language.system": "Système ({language})", "settings.language.summary": "En l'absence de traduction, l'anglais est utilisé par défaut, ce qui permet de continuer à utiliser les localisations partiellement traduites.", - "settings.autogit.title": "AutoGit", + "settings.autogit.title": "Git", + "settings.git.enable": "Activer les fonctionnalités Git", + "settings.git.enableDescription": "Affichez l'état Git, l'historique, les modifications, les validations, la synchronisation et les commandes AutoGit dans l'ensemble de Tolaria.", "settings.autogit.description.enabled": "Crée automatiquement des points de contrôle Git conservateurs après les pauses d'édition ou lorsque l'application n'est plus active.", "settings.autogit.description.disabled": "AutoGit n'est pas disponible tant que le coffre-fort actuel n'est pas compatible avec Git. Veuillez d'abord initialiser Git pour ce coffre-fort.", + "settings.autogit.description.gitDisabled": "AutoGit n'est pas disponible lorsque les fonctionnalités Git sont désactivées.", "settings.autogit.enable": "Activer AutoGit", "settings.autogit.enableDescription": "Lorsque cette option est activée, Tolaria valide et pousse automatiquement les modifications locales enregistrées après une pause d'inactivité ou lorsque l'application devient inactive.", "settings.autogit.idleThreshold": "Seuil d'inactivité (secondes)", diff --git a/src/lib/locales/it-IT.json b/src/lib/locales/it-IT.json index 0d7d8e12..48ced8ce 100644 --- a/src/lib/locales/it-IT.json +++ b/src/lib/locales/it-IT.json @@ -139,9 +139,12 @@ "settings.language.label": "Lingua di visualizzazione", "settings.language.system": "Sistema ({language})", "settings.language.summary": "In caso di traduzioni mancanti, viene utilizzato l'inglese come lingua predefinita, in modo che le localizzazioni parzialmente tradotte rimangano utilizzabili.", - "settings.autogit.title": "AutoGit", + "settings.autogit.title": "Git", + "settings.git.enable": "Abilita le funzionalità Git", + "settings.git.enableDescription": "Mostra lo stato di Git, la cronologia, le modifiche, i commit, la sincronizzazione e i controlli di AutoGit in tutta Tolaria.", "settings.autogit.description.enabled": "Crea automaticamente checkpoint Git conservativi dopo le pause di modifica o quando l'app non è più attiva.", "settings.autogit.description.disabled": "AutoGit non è disponibile finché il vault corrente non è abilitato per Git. Innanzitutto, inizializza Git per questo vault.", + "settings.autogit.description.gitDisabled": "AutoGit non è disponibile quando le funzionalità Git sono disabilitate.", "settings.autogit.enable": "Abilita AutoGit", "settings.autogit.enableDescription": "Se abilitato, Tolaria eseguirà automaticamente il commit e il push delle modifiche locali salvate dopo una pausa di inattività o quando l'app diventa inattiva.", "settings.autogit.idleThreshold": "Soglia di inattività (secondi)", diff --git a/src/lib/locales/ja-JP.json b/src/lib/locales/ja-JP.json index 59c63c8a..472e3808 100644 --- a/src/lib/locales/ja-JP.json +++ b/src/lib/locales/ja-JP.json @@ -139,9 +139,12 @@ "settings.language.label": "表示言語", "settings.language.system": "システム({language})", "settings.language.summary": "翻訳が欠落している場合は英語にフォールバックするため、部分的に翻訳されたロケールも引き続き使用できます。", - "settings.autogit.title": "AutoGit", + "settings.autogit.title": "Git", + "settings.git.enable": "Git機能を有効にする", + "settings.git.enableDescription": "Tolaria全体でGitのステータス、履歴、変更、コミット、同期、AutoGitコントロールを表示します。", "settings.autogit.description.enabled": "編集が一時停止した後、またはアプリがアクティブでなくなったときに、控えめなGitチェックポイントを自動的に作成します。", "settings.autogit.description.disabled": "現在のボルトがGit対応になるまで、AutoGitは利用できません。まず、このボールトでGitを初期化してください。", + "settings.autogit.description.gitDisabled": "Git機能が無効になっている間、AutoGitは利用できません。", "settings.autogit.enable": "AutoGitを有効にする", "settings.autogit.enableDescription": "有効にすると、Tolaria はアイドル状態になった後、またはアプリが非アクティブになった後に、保存されたローカル変更を自動的にコミットしてプッシュします。", "settings.autogit.idleThreshold": "アイドルしきい値(秒)", diff --git a/src/lib/locales/ko-KR.json b/src/lib/locales/ko-KR.json index f8f66b1d..7893e1a1 100644 --- a/src/lib/locales/ko-KR.json +++ b/src/lib/locales/ko-KR.json @@ -139,9 +139,12 @@ "settings.language.label": "표시 언어", "settings.language.system": "시스템 ({language})", "settings.language.summary": "번역이 누락된 경우 영어가 기본으로 설정되므로, 부분적으로만 번역된 로케일도 계속 사용할 수 있습니다.", - "settings.autogit.title": "AutoGit", + "settings.autogit.title": "Git", + "settings.git.enable": "Git 기능 활성화", + "settings.git.enableDescription": "Tolaria 전체에서 Git 상태, 기록, 변경 사항, 커밋, 동기화 및 AutoGit 컨트롤을 표시합니다.", "settings.autogit.description.enabled": "편집이 일시 중지되거나 앱이 더 이상 활성 상태가 아닐 때 보수적인 Git 체크포인트를 자동으로 생성합니다.", "settings.autogit.description.disabled": "현재 Vault가 Git 지원 상태가 될 때까지 AutoGit을 사용할 수 없습니다. 먼저 Vault에 대해 Git을 초기화하세요.", + "settings.autogit.description.gitDisabled": "Git 기능이 비활성화된 상태에서는 AutoGit을 사용할 수 없습니다.", "settings.autogit.enable": "AutoGit 활성화", "settings.autogit.enableDescription": "이 기능을 활성화하면, Tolaria는 유휴 시간 중 일시 중지되거나 앱이 비활성화된 후 저장된 로컬 변경 사항을 자동으로 커밋하고 푸시합니다.", "settings.autogit.idleThreshold": "유휴 시간 임계값(초)", diff --git a/src/lib/locales/pl-PL.json b/src/lib/locales/pl-PL.json index c2f7aea2..e967cd15 100644 --- a/src/lib/locales/pl-PL.json +++ b/src/lib/locales/pl-PL.json @@ -139,9 +139,12 @@ "settings.language.label": "Język wyświetlania", "settings.language.system": "Systemowy ({language})", "settings.language.summary": "Brakujące tłumaczenia są zastępowane angielskim, dzięki czemu częściowo przetłumaczone lokalizacje pozostają użyteczne.", - "settings.autogit.title": "AutoGit", + "settings.autogit.title": "Git", + "settings.git.enable": "Włącz funkcje Git", + "settings.git.enableDescription": "Wyświetlaj status Git, historię, zmiany, zatwierdzenia, synchronizację i elementy sterujące AutoGit w całej aplikacji Tolaria.", "settings.autogit.description.enabled": "Automatycznie twórz zachowawcze punkty kontrolne Git po przerwach w edycji lub gdy aplikacja jest nieaktywna.", "settings.autogit.description.disabled": "AutoGit jest niedostępny, dopóki bieżący sejf nie będzie obsługiwany przez Git. Najpierw zainicjalizuj Git dla tego sejfu.", + "settings.autogit.description.gitDisabled": "AutoGit jest niedostępny, gdy funkcje Git są wyłączone.", "settings.autogit.enable": "Włącz AutoGit", "settings.autogit.enableDescription": "Po włączeniu Tolaria automatycznie zatwierdzi i wypchnie zapisane lokalne zmiany po przerwie w aktywności lub gdy aplikacja stanie się nieaktywna.", "settings.autogit.idleThreshold": "Próg bezczynności (sekundy)", diff --git a/src/lib/locales/pt-BR.json b/src/lib/locales/pt-BR.json index da71ea4c..a2ba87fa 100644 --- a/src/lib/locales/pt-BR.json +++ b/src/lib/locales/pt-BR.json @@ -139,9 +139,12 @@ "settings.language.label": "Idioma de exibição", "settings.language.system": "Sistema ({language})", "settings.language.summary": "Quando faltam traduções, o sistema usa o inglês como padrão para que as localizações parcialmente traduzidas continuem utilizáveis.", - "settings.autogit.title": "AutoGit", + "settings.autogit.title": "Git", + "settings.git.enable": "Ativar recursos do Git", + "settings.git.enableDescription": "Mostrar status do Git, histórico, alterações, commits, sincronização e controles do AutoGit em toda a Tolaria.", "settings.autogit.description.enabled": "Cria automaticamente checkpoints conservativos do Git após pausas na edição ou quando o aplicativo não estiver mais ativo.", "settings.autogit.description.disabled": "O AutoGit não estará disponível até que o cofre atual seja habilitado para Git. Primeiro, inicialize o Git para este cofre.", + "settings.autogit.description.gitDisabled": "O AutoGit não está disponível enquanto os recursos do Git estiverem desativados.", "settings.autogit.enable": "Ativar o AutoGit", "settings.autogit.enableDescription": "Quando ativado, o Tolaria fará commit e push automaticamente das alterações locais salvas após uma pausa por inatividade ou depois que o aplicativo ficar inativo.", "settings.autogit.idleThreshold": "Limite de inatividade (segundos)", diff --git a/src/lib/locales/pt-PT.json b/src/lib/locales/pt-PT.json index 4eb7bbb3..9f6bee1e 100644 --- a/src/lib/locales/pt-PT.json +++ b/src/lib/locales/pt-PT.json @@ -139,9 +139,12 @@ "settings.language.label": "Idioma de apresentação", "settings.language.system": "Sistema ({language})", "settings.language.summary": "Quando faltam traduções, é utilizado o inglês como alternativa, para que as localizações parcialmente traduzidas continuem a poder ser utilizadas.", - "settings.autogit.title": "AutoGit", + "settings.autogit.title": "Git", + "settings.git.enable": "Ativar funcionalidades do Git", + "settings.git.enableDescription": "Mostrar o estado do Git, o histórico, as alterações, os commits, a sincronização e os controlos do AutoGit em toda a Tolaria.", "settings.autogit.description.enabled": "Cria automaticamente checkpoints conservativos do Git após pausas na edição ou quando a aplicação deixa de estar ativa.", "settings.autogit.description.disabled": "O AutoGit não está disponível enquanto o cofre atual não estiver ativado para Git. Primeiro, inicialize o Git para este cofre.", + "settings.autogit.description.gitDisabled": "O AutoGit não está disponível enquanto as funcionalidades do Git estiverem desativadas.", "settings.autogit.enable": "Ativar o AutoGit", "settings.autogit.enableDescription": "Quando ativado, o Tolaria fará automaticamente o commit e o push das alterações locais guardadas após uma pausa por inatividade ou após a aplicação ficar inativa.", "settings.autogit.idleThreshold": "Limite de inatividade (segundos)", diff --git a/src/lib/locales/ru-RU.json b/src/lib/locales/ru-RU.json index 30a2d4e3..0848c13b 100644 --- a/src/lib/locales/ru-RU.json +++ b/src/lib/locales/ru-RU.json @@ -139,9 +139,12 @@ "settings.language.label": "Язык интерфейса", "settings.language.system": "Системный ({language})", "settings.language.summary": "Если перевод отсутствует, используется английский текст, поэтому частично переведенные локали остаются пригодными для использования.", - "settings.autogit.title": "AutoGit", + "settings.autogit.title": "Git", + "settings.git.enable": "Включить функции Git", + "settings.git.enableDescription": "Отображать статус Git, историю, изменения, коммиты, синхронизацию и элементы управления AutoGit во всей Tolaria.", "settings.autogit.description.enabled": "Автоматически создавать консервативные контрольные точки Git после пауз в редактировании или когда приложение перестает быть активным.", "settings.autogit.description.disabled": "Функция AutoGit недоступна, пока для текущего хранилища не включена поддержка Git. Сначала инициализируйте Git для этого хранилища.", + "settings.autogit.description.gitDisabled": "AutoGit недоступен, когда функции Git отключены.", "settings.autogit.enable": "Включить AutoGit", "settings.autogit.enableDescription": "Если эта функция включена, Tolaria будет автоматически выполнять коммит и пуш сохраненных локальных изменений после паузы бездействия или после того, как приложение станет неактивным.", "settings.autogit.idleThreshold": "Порог бездействия (секунды)", diff --git a/src/lib/locales/vi.json b/src/lib/locales/vi.json index 9c6489aa..58a50611 100644 --- a/src/lib/locales/vi.json +++ b/src/lib/locales/vi.json @@ -139,9 +139,12 @@ "settings.language.label": "Ngôn ngữ hiển thị", "settings.language.system": "Hệ thống ({language})", "settings.language.summary": "Các bản dịch còn thiếu sẽ quay về tiếng Anh để những ngôn ngữ dịch dở vẫn dùng được.", - "settings.autogit.title": "AutoGit", + "settings.autogit.title": "Git", + "settings.git.enable": "Bật tính năng Git", + "settings.git.enableDescription": "Hiển thị trạng thái Git, lịch sử, thay đổi, commit, đồng bộ và các điều khiển AutoGit trên toàn Tolaria.", "settings.autogit.description.enabled": "Tự động tạo các điểm kiểm tra Git an toàn sau khi bạn ngừng chỉnh sửa hoặc khi ứng dụng không còn hoạt động.", "settings.autogit.description.disabled": "AutoGit chưa khả dụng cho đến khi kho hiện tại được bật Git. Hãy khởi tạo Git cho kho này trước.", + "settings.autogit.description.gitDisabled": "AutoGit không khả dụng khi các tính năng Git bị tắt.", "settings.autogit.enable": "Bật AutoGit", "settings.autogit.enableDescription": "Khi bật, Tolaria sẽ tự động commit và đẩy các thay đổi đã lưu sau một khoảng nghỉ hoặc khi ứng dụng chuyển sang trạng thái không hoạt động.", "settings.autogit.idleThreshold": "Ngưỡng nhàn rỗi (giây)", diff --git a/src/lib/locales/zh-CN.json b/src/lib/locales/zh-CN.json index b8ae9e1e..991405e1 100644 --- a/src/lib/locales/zh-CN.json +++ b/src/lib/locales/zh-CN.json @@ -139,9 +139,12 @@ "settings.language.label": "显示语言", "settings.language.system": "系统({language})", "settings.language.summary": "缺失的翻译会回退到英文,因此部分翻译的语言也能正常使用。", - "settings.autogit.title": "AutoGit", + "settings.autogit.title": "Git", + "settings.git.enable": "启用 Git 功能", + "settings.git.enableDescription": "在整个 Tolaria 中显示 Git 状态、历史记录、更改、提交、同步和 AutoGit 控件。", "settings.autogit.description.enabled": "在编辑暂停或应用不再活跃后,自动创建保守的 Git 检查点。", "settings.autogit.description.disabled": "当前仓库启用 Git 后才能使用 AutoGit。请先为此仓库初始化 Git。", + "settings.autogit.description.gitDisabled": "禁用 Git 功能时,AutoGit 将不可用。", "settings.autogit.enable": "启用 AutoGit", "settings.autogit.enableDescription": "启用后,Tolaria 会在空闲暂停或应用变为非活跃后自动提交并推送保存的本地更改。", "settings.autogit.idleThreshold": "空闲阈值(秒)", diff --git a/src/lib/locales/zh-TW.json b/src/lib/locales/zh-TW.json index 0a51f1a5..c3377a75 100644 --- a/src/lib/locales/zh-TW.json +++ b/src/lib/locales/zh-TW.json @@ -139,9 +139,12 @@ "settings.language.label": "顯示語言", "settings.language.system": "系統({language})", "settings.language.summary": "缺失的翻譯會回退到英文,因此部分翻譯的語言也能正常使用。", - "settings.autogit.title": "AutoGit", + "settings.autogit.title": "Git", + "settings.git.enable": "啟用 Git 功能", + "settings.git.enableDescription": "在整個 Tolaria 中顯示 Git 狀態、歷史記錄、變更、提交、同步和 AutoGit 控制項。", "settings.autogit.description.enabled": "在編輯暫停或應用不再活躍後,自動建立保守的 Git 檢查點。", "settings.autogit.description.disabled": "當前倉庫啟用 Git 後才能使用 AutoGit。請先為此倉庫初始化 Git。", + "settings.autogit.description.gitDisabled": "停用 Git 功能時,無法使用 AutoGit。", "settings.autogit.enable": "啟用 AutoGit", "settings.autogit.enableDescription": "啟用後,Tolaria 會在空閒暫停或應用變為非活躍後自動提交併推送儲存的本地更改。", "settings.autogit.idleThreshold": "空閒閾值(秒)", diff --git a/src/lib/productAnalytics.ts b/src/lib/productAnalytics.ts index 576e2bb8..6fd87907 100644 --- a/src/lib/productAnalytics.ts +++ b/src/lib/productAnalytics.ts @@ -63,6 +63,12 @@ export function trackAiFeaturesEnabledChanged(enabled: boolean): void { }) } +export function trackGitFeaturesEnabledChanged(enabled: boolean): void { + trackEvent('git_features_visibility_changed', { + enabled: numericFlag(enabled), + }) +} + export function trackDefaultNoteWidthChanged(mode: NoteWidthMode): void { trackEvent('note_width_default_changed', { mode }) } diff --git a/src/mock-tauri/mock-handlers.coverage.test.ts b/src/mock-tauri/mock-handlers.coverage.test.ts index 5ef9df12..f05a9c07 100644 --- a/src/mock-tauri/mock-handlers.coverage.test.ts +++ b/src/mock-tauri/mock-handlers.coverage.test.ts @@ -159,6 +159,7 @@ describe('mockHandlers coverage', () => { expect(mockHandlers.get_settings()).toEqual({ auto_pull_interval_minutes: 5, + git_enabled: null, autogit_enabled: true, autogit_idle_threshold_seconds: 90, autogit_inactive_threshold_seconds: 30, diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index b3df69ce..be775dfa 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -102,6 +102,7 @@ const mockSavedSinceCommit = new Set() let mockSettings: Settings = { auto_pull_interval_minutes: 5, + git_enabled: null, autogit_enabled: false, autogit_idle_threshold_seconds: 90, autogit_inactive_threshold_seconds: 30, @@ -498,6 +499,7 @@ export const mockHandlers: Record any> = { const s = args.settings mockSettings = { auto_pull_interval_minutes: s.auto_pull_interval_minutes ?? 5, + git_enabled: s.git_enabled ?? null, autogit_enabled: s.autogit_enabled ?? false, autogit_idle_threshold_seconds: s.autogit_idle_threshold_seconds ?? 90, autogit_inactive_threshold_seconds: s.autogit_inactive_threshold_seconds ?? 30, diff --git a/src/types.ts b/src/types.ts index a231153f..1b6f6882 100644 --- a/src/types.ts +++ b/src/types.ts @@ -106,6 +106,7 @@ export interface ModifiedFile { export interface Settings { auto_pull_interval_minutes: number | null + git_enabled?: boolean | null autogit_enabled?: boolean | null autogit_idle_threshold_seconds?: number | null autogit_inactive_threshold_seconds?: number | null @@ -190,6 +191,7 @@ export interface AllNotesConfig { export type NoteLayout = 'centered' | 'left' export type NoteWidthMode = 'normal' | 'wide' +export type GitSetupPreference = 'prompt' | 'never' /** Vault-scoped UI configuration stored locally per vault path. */ export interface VaultConfig { @@ -197,6 +199,7 @@ export interface VaultConfig { view_mode: string | null editor_mode: string | null note_layout?: NoteLayout | null + git_setup_preference?: GitSetupPreference | null ai_agent_permission_mode?: AiAgentPermissionMode | null tag_colors: Record | null status_colors: Record | null diff --git a/src/utils/configMigration.ts b/src/utils/configMigration.ts index 30a4a646..d7fb524a 100644 --- a/src/utils/configMigration.ts +++ b/src/utils/configMigration.ts @@ -12,6 +12,8 @@ const LS_KEYS = { propertyModes: APP_STORAGE_KEYS.propertyModes, } as const +type JsonRecordConfigKey = 'tag_colors' | 'status_colors' | 'property_display_modes' + function readJson(key: string): T | null { try { const raw = localStorage.getItem(key) @@ -21,71 +23,91 @@ function readJson(key: string): T | null { } } +function createDefaultVaultConfig(): VaultConfig { + return { + zoom: null, + view_mode: null, + editor_mode: null, + git_setup_preference: 'prompt', + tag_colors: null, + status_colors: null, + property_display_modes: null, + inbox: null, + } +} + +function migrationAlreadyCompleted(): boolean { + try { + return localStorage.getItem(MIGRATION_FLAG) === '1' + } catch { + return true + } +} + +function markMigrationCompleted() { + try { + localStorage.setItem(MIGRATION_FLAG, '1') + } catch { + // Ignore localStorage failures; the loaded config remains usable. + } +} + +function applyZoomMigration(result: VaultConfig) { + if (result.zoom !== null) return + try { + const raw = getAppStorageItem('zoom') + const value = raw === null ? null : Number(raw) + if (value !== null && value >= 80 && value <= 150) result.zoom = value / 100 + } catch { + // Ignore malformed legacy values. + } +} + +function applyViewModeMigration(result: VaultConfig) { + if (result.view_mode !== null) return + try { + const raw = getAppStorageItem('viewMode') + if (raw === 'editor-only' || raw === 'editor-list' || raw === 'all') result.view_mode = raw + } catch { + // Ignore malformed legacy values. + } +} + +function hasRecordValues(value: Record | null): value is Record { + return value !== null && Object.keys(value).length > 0 +} + +function applyJsonRecordMigration( + result: VaultConfig, + field: JsonRecordConfigKey, + primaryKey: string, + legacyKey: string, +) { + if (result[field] !== null) return + const values = readJson>(primaryKey) ?? readJson>(legacyKey) + if (hasRecordValues(values)) result[field] = values +} + /** * One-time migration: read localStorage values and merge into vault config. * Returns the merged config. If already migrated (flag set), returns the loaded config unchanged. * Passing null for `loaded` means the vault file didn't exist yet. */ export function migrateLocalStorageToVaultConfig(loaded: VaultConfig | null): VaultConfig { - const base: VaultConfig = loaded ?? { - zoom: null, view_mode: null, editor_mode: null, tag_colors: null, - status_colors: null, property_display_modes: null, inbox: null, - } + const base = loaded ?? createDefaultVaultConfig() copyLegacyAppStorageKeys() - // Skip migration if already done - try { - if (localStorage.getItem(MIGRATION_FLAG) === '1') return base - } catch { - return base - } + if (migrationAlreadyCompleted()) return base const result = { ...base } - // Zoom (localStorage stores as string "80"–"150", vault config stores as fraction 0.8–1.5) - if (result.zoom === null) { - try { - const raw = getAppStorageItem('zoom') - if (raw !== null) { - const val = Number(raw) - if (val >= 80 && val <= 150) result.zoom = val / 100 - } - } catch { /* ignore */ } - } - - // View mode - if (result.view_mode === null) { - try { - const raw = getAppStorageItem('viewMode') - if (raw === 'editor-only' || raw === 'editor-list' || raw === 'all') { - result.view_mode = raw - } - } catch { /* ignore */ } - } - - // Tag colors - if (result.tag_colors === null) { - const colors = readJson>(LS_KEYS.tagColors) ?? readJson>('laputa:tag-color-overrides') - if (colors && Object.keys(colors).length > 0) result.tag_colors = colors - } - - // Status colors - if (result.status_colors === null) { - const colors = readJson>(LS_KEYS.statusColors) ?? readJson>('laputa:status-color-overrides') - if (colors && Object.keys(colors).length > 0) result.status_colors = colors - } - - // Property display modes - if (result.property_display_modes === null) { - const modes = readJson>(LS_KEYS.propertyModes) ?? readJson>('laputa:display-mode-overrides') - if (modes && Object.keys(modes).length > 0) result.property_display_modes = modes - } - - // Mark migration as done - try { - localStorage.setItem(MIGRATION_FLAG, '1') - } catch { /* ignore */ } + applyZoomMigration(result) + applyViewModeMigration(result) + applyJsonRecordMigration(result, 'tag_colors', LS_KEYS.tagColors, 'laputa:tag-color-overrides') + applyJsonRecordMigration(result, 'status_colors', LS_KEYS.statusColors, 'laputa:status-color-overrides') + applyJsonRecordMigration(result, 'property_display_modes', LS_KEYS.propertyModes, 'laputa:display-mode-overrides') + markMigrationCompleted() return result } diff --git a/src/utils/vaultConfigStore.ts b/src/utils/vaultConfigStore.ts index 5ba25f94..aa4d9185 100644 --- a/src/utils/vaultConfigStore.ts +++ b/src/utils/vaultConfigStore.ts @@ -9,6 +9,7 @@ type Listener = () => void const DEFAULT_CONFIG: VaultConfig = { zoom: null, view_mode: null, editor_mode: null, note_layout: null, + git_setup_preference: 'prompt', ai_agent_permission_mode: DEFAULT_AI_AGENT_PERMISSION_MODE, tag_colors: null, status_colors: null, property_display_modes: null, inbox: null, allNotes: null, @@ -52,6 +53,7 @@ function normalizeVaultConfig(next: VaultConfig): VaultConfig { return { ...DEFAULT_CONFIG, ...next, + git_setup_preference: next.git_setup_preference === 'never' ? 'never' : 'prompt', ai_agent_permission_mode: normalizeAiAgentPermissionMode(next.ai_agent_permission_mode), } }