Compare commits

...

11 Commits

Author SHA1 Message Date
lucaronin
9be428aad3 fix: sanitize appimage mcp node launches 2026-05-13 15:21:36 +02:00
lucaronin
b2d23a8559 fix: dedupe calendar stable releases 2026-05-13 14:47:26 +02:00
lucaronin
d6ad2ff836 test: isolate pi agent config tests 2026-05-13 14:32:04 +02:00
lucaronin
6ac8f822ff test: stabilize autogit smoke setup 2026-05-13 14:18:03 +02:00
lucaronin
b0c8eff6c7 fix: handle autogit author failures 2026-05-13 14:12:23 +02:00
lucaronin
ae7a468310 test: guard reload editing loop 2026-05-13 13:24:41 +02:00
lucaronin
84a993b948 test: guard fresh-note enter selection 2026-05-13 13:03:36 +02:00
lucaronin
2a5ebcf73d fix: prevent whiteboard dialog crash 2026-05-13 12:42:37 +02:00
lucaronin
09978cf8e5 fix: preserve ampersand in native commit menu 2026-05-13 11:52:06 +02:00
lucaronin
0e981228e2 fix: point release history link to public site 2026-05-13 11:41:36 +02:00
lucaronin
b77ebd2a02 docs: add release notes for v2026-05-12 2026-05-13 11:33:02 +02:00
38 changed files with 765 additions and 181 deletions

View File

@@ -820,7 +820,7 @@ Tolaria delegates remote auth to the user's system git setup:
- `CloneVaultModal` captures a remote URL and local destination
- `clone_git_repo` and `create_getting_started_vault` both run system git clone work in blocking Tokio tasks so clone UIs stay responsive
- On macOS, system-git commands prefer the user's login-shell `git` and `PATH`, and `git_add_remote` preflights HTTPS remotes through `git credential fill` so Keychain can prompt/grant access before the first fetch or push
- On Linux AppImage launches, every system-git command removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` before spawning `git`, so helpers like `git-remote-https` bind against the host git/library stack instead of Tolaria's bundled WebKit/AppImage libraries
- On Linux AppImage launches, every system-git command and MCP Node subprocess removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` before spawning, so helpers like `git-remote-https` and system `node` bind against the host library stack instead of Tolaria's bundled WebKit/AppImage libraries
- On Linux AppImage Wayland launches, startup environment safeguards also set `GTK_IM_MODULE=fcitx` when common input-method variables already indicate fcitx and the user has not explicitly chosen a GTK IM module, allowing WebKitGTK editor input to reach fcitx5 on compositors such as niri without overriding deliberate `GTK_IM_MODULE` choices.
- `git_add_remote` uses the same system git path and refuses remotes whose history is unrelated or ahead of the local vault
- Existing `git_pull` / `git_push` commands keep surfacing raw git errors, and clone commands fail fast when git wants interactive terminal input

View File

@@ -539,7 +539,7 @@ Tolaria no longer implements provider-specific OAuth or remote-repository APIs.
1. User opens `CloneVaultModal` from onboarding or the vault menu
2. User pastes any git URL and chooses a local destination
3. The `clone_git_repo()` Tauri command runs `git clone` inside a blocking Tokio task so the Tauri window stays responsive during slow or failing clones
4. Linux AppImage builds strip AppImage loader variables from system-git subprocesses before spawning `git`, keeping `git-remote-https` on the host git/library stack
4. Linux AppImage builds strip AppImage loader variables from system-git and MCP Node subprocesses before spawning them, keeping `git-remote-https` and system `node` on the host library stack
5. `git_push()` / `git_pull()` continue to use the same system git path
6. On macOS, `git_add_remote()` asks Git's credential helper for HTTPS credentials before the first fetch so Keychain can grant access to the same saved credential item the shell uses
7. Clone commands disable interactive terminal / askpass prompts and surface the git failure back to the UI instead of freezing the app waiting for input

View File

@@ -69,7 +69,7 @@ pnpm playwright:regression # Full Playwright regression suite
`create_getting_started_vault` clones the public starter repo and then removes every git remote from the new local copy. That means Getting Started vaults open local-only by default. Users connect a compatible remote later through the bottom-bar `No remote` chip or the command palette, both of which feed the same `AddRemoteModal` and `git_add_remote` backend flow.
Linux AppImage builds still use the user's system `git`. Before Tolaria spawns that `git` process, it removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` so HTTPS clone helpers use the host git libraries instead of bundled AppImage libraries.
Linux AppImage builds still use the user's system `git` and `node`. Before Tolaria spawns those Git or MCP Node subprocesses, it removes AppImage loader overrides such as `LD_LIBRARY_PATH`, `LD_PRELOAD`, and `GIT_EXEC_PATH` so HTTPS clone helpers and MCP tooling use the host library stack instead of bundled AppImage libraries.
## Multiple Vaults At The Same Time

View File

@@ -62,6 +62,9 @@ files:
command.git.resolveConflicts: 871ac36968cfca3d2297a89b28b25dee
command.git.viewChanges: b2699edf69d8e1031afce2b2f94e5c8d
git.repository.select: 33fcf2b3ec4686d9cd06051c726d0ba2
git.toast.autoGitFailed: 3e5d7e183a78e1f54c87ad0dba8a302b
git.toast.commitFailed: bb50986d29c30042bed1446f8adaa544
git.toast.missingAuthor: 0d4e7a7d6b6a3f883146dd86c9cdca09
command.view.editorOnly: 8355e056b32086b9190d639be290dda8
command.view.editorNoteList: b9604b1609eb6f581cd9570dca72d3f0
command.view.fullLayout: 1cebdf839eee2b1713828731aaa3b507

View File

@@ -0,0 +1,21 @@
## New Features
- 🧮 **Math Blocks from Slash Commands** — Insert math expressions directly from the editor slash menu.
- 🗓️ **Flexible Date Display** — Choose how dates appear across note lists, properties, and inspector views.
- 🧭 **Mounted Workspaces** — Bring separate vault workspaces into Tolaria while keeping their Git boundaries intact.
- 📂 **Collapsed Sidebar Reopen Button** — Reopen the sidebar more easily after switching to a focused writing layout.
## Improvements
- 🖼️ **Better Media and Embed Handling** — Note-relative images, multimedia URLs, file blocks, and rich-editor media reloads behave more consistently.
- 🤖 **More Reliable Local Agent Launches** — Claude, Gemini, OpenCode, and MCP server setup now handle modern CLI behavior and Windows paths more gracefully.
- 🧱 **Smoother Editor Navigation** — Breadcrumb titles, neighborhood toggles, previous-list recovery, note renaming, and editor reloads now preserve more context.
- 🌐 **Cleaner Release and Docs Publishing** — Release pages, GitHub Pages deployment, custom-domain docs, and updater metadata were hardened.
## Stability and Fixes
- Whiteboard dialogs, mermaid previews, tldraw context menus, and dark-mode rendering are more resilient.
- The editor now guards more stale-selection, stale-block, paste, tab-switch, and save-before-switch edge cases.
- Security and quality hardening resolved multiple Codacy findings around mock vault APIs, regexes, object injection, focus handling, and history flows.
- Test coverage was expanded for note history edits, telemetry onboarding, rich-editor media reloads, Cmd+N persistence, save-before-switch behavior, and editor draft preservation.
- Internal app orchestration, vault setup, inbox advance, and Git file workflow code was split into smaller focused pieces without changing the released user model.

View File

@@ -3,6 +3,7 @@ use std::path::{Path, PathBuf};
use std::process::{Child, Command};
mod paths;
mod subprocess;
const MCP_SERVER_NAME: &str = "tolaria";
const LEGACY_MCP_SERVER_NAME: &str = "laputa";
@@ -76,7 +77,7 @@ fn user_shell_candidates() -> Vec<PathBuf> {
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
crate::hidden_command(shell)
subprocess::command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
@@ -104,7 +105,7 @@ fn first_existing_path(stdout: &str) -> Option<PathBuf> {
}
fn verify_node_version(node: &Path) -> Result<(), String> {
let output = crate::hidden_command(node)
let output = subprocess::command(node)
.arg("--version")
.output()
.map_err(|e| format!("Failed to run {} --version: {e}", node.display()))?;
@@ -143,9 +144,9 @@ fn node_major_version(version: &str) -> Option<u32> {
fn node_lookup_command() -> Command {
#[cfg(windows)]
let mut command = crate::hidden_command("where.exe");
let mut command = subprocess::command("where.exe");
#[cfg(not(windows))]
let mut command = crate::hidden_command("which");
let mut command = subprocess::command("which");
command.arg("node");
command
@@ -397,7 +398,8 @@ pub fn spawn_ws_bridge_with_paths(
let vault_path = vault_path.as_ref();
let active_vault_paths = active_vault_paths_json(vault_path, vault_paths);
let child = crate::hidden_command(node)
let mut command = subprocess::command(node);
let child = command
.arg(&script)
.env("VAULT_PATH", vault_path)
.env("VAULT_PATHS", active_vault_paths)

View File

@@ -0,0 +1,78 @@
use std::ffi::OsStr;
use std::process::Command;
#[cfg(any(test, all(desktop, target_os = "linux")))]
const APPIMAGE_ENV_REMOVALS: [&str; 3] = ["LD_LIBRARY_PATH", "LD_PRELOAD", "GIT_EXEC_PATH"];
pub(super) fn command(program: impl AsRef<OsStr>) -> Command {
let mut command = crate::hidden_command(program);
sanitize_appimage_env(&mut command);
command
}
#[cfg(all(desktop, target_os = "linux"))]
fn sanitize_appimage_env(command: &mut Command) {
sanitize_appimage_env_for_launch(command, appimage_env_present());
}
#[cfg(not(all(desktop, target_os = "linux")))]
fn sanitize_appimage_env(_command: &mut Command) {}
#[cfg(any(test, all(desktop, target_os = "linux")))]
fn sanitize_appimage_env_for_launch(command: &mut Command, is_appimage: bool) {
if !is_appimage {
return;
}
for key in APPIMAGE_ENV_REMOVALS {
command.env_remove(key);
}
}
#[cfg(all(desktop, target_os = "linux"))]
fn appimage_env_present() -> bool {
["APPIMAGE", "APPDIR"]
.into_iter()
.any(|key| std::env::var(key).is_ok_and(|value| !value.trim().is_empty()))
}
#[cfg(test)]
mod tests {
use super::*;
fn command_envs(command: &Command) -> std::collections::HashMap<String, Option<String>> {
command
.get_envs()
.map(|(key, value)| {
(
key.to_string_lossy().to_string(),
value.map(|entry| entry.to_string_lossy().to_string()),
)
})
.collect()
}
#[test]
fn appimage_mcp_subprocesses_remove_loader_env() {
let mut command = crate::hidden_command("node");
sanitize_appimage_env_for_launch(&mut command, true);
let envs = command_envs(&command);
for key in APPIMAGE_ENV_REMOVALS {
assert_eq!(envs.get(key), Some(&None));
}
}
#[test]
fn non_appimage_mcp_subprocesses_keep_parent_env_unmodified() {
let mut command = crate::hidden_command("node");
sanitize_appimage_env_for_launch(&mut command, false);
let envs = command_envs(&command);
for key in APPIMAGE_ENV_REMOVALS {
assert!(!envs.contains_key(key));
}
}
}

View File

@@ -2,6 +2,7 @@
use crate::window_state::MAIN_WINDOW_LABEL;
use serde::{Deserialize, Deserializer};
use std::{
borrow::Cow,
collections::{BTreeMap, HashSet},
error::Error,
sync::OnceLock,
@@ -253,6 +254,14 @@ fn native_window_menu_submenu_id(target_os: &str) -> Option<&'static str> {
}
}
fn native_menu_label(label: &str) -> Cow<'_, str> {
if label.contains('&') {
Cow::Owned(label.replace('&', "&&"))
} else {
Cow::Borrowed(label)
}
}
fn build_manifest_menu_item(
app: &App,
item: &ManifestMenuItem,
@@ -263,8 +272,11 @@ fn build_manifest_menu_item(
let Some(label) = item.label(std::env::consts::OS) else {
return Ok(None);
};
let label = native_menu_label(label);
let mut builder = MenuItemBuilder::new(label).id(id).enabled(item.enabled());
let mut builder = MenuItemBuilder::new(label.as_ref())
.id(id)
.enabled(item.enabled());
if let Some(accelerator) = item.accelerator(manifest()) {
builder = builder.accelerator(accelerator);
}
@@ -641,4 +653,28 @@ mod tests {
assert_eq!(native_window_menu_submenu_id("windows"), None);
assert_eq!(native_window_menu_submenu_id("linux"), None);
}
#[test]
fn native_menu_labels_escape_literal_ampersands() {
assert_eq!(native_menu_label("Commit & Push"), "Commit && Push");
assert_eq!(
native_menu_label("Research && Development"),
"Research &&&& Development"
);
}
#[test]
fn native_menu_labels_without_ampersands_are_unchanged() {
assert_eq!(native_menu_label("Pull from Remote"), "Pull from Remote");
}
#[test]
fn vault_commit_push_menu_label_is_native_menu_safe() {
let item = menu_item_by_id("vault-commit-push");
let label = item.label("windows").expect("commit push label exists");
assert_eq!(label, "Commit & Push");
assert_eq!(native_menu_label(label), "Commit && Push");
assert_eq!(item.accelerator(manifest()), None);
}
}

View File

@@ -219,6 +219,9 @@ mod tests {
use super::*;
use std::ffi::OsStr;
use std::path::PathBuf;
use std::sync::Mutex;
static PI_AGENT_ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard {
key: &'static str,
@@ -270,7 +273,10 @@ mod tests {
#[test]
fn command_sets_vault_cwd_closed_stdin_and_config_dir() {
let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap();
let source_agent_dir = tempfile::tempdir().unwrap();
let agent_dir = tempfile::tempdir().unwrap();
let _guard = EnvGuard::set("PI_CODING_AGENT_DIR", source_agent_dir.path());
let command = build_command(&PathBuf::from("pi"), &request(), agent_dir.path()).unwrap();
let actual_args: Vec<&OsStr> = command.get_args().collect();
let config_dir = command
@@ -301,6 +307,7 @@ mod tests {
#[test]
fn command_seeds_temp_agent_dir_from_existing_pi_config() {
let _env_lock = PI_AGENT_ENV_LOCK.lock().unwrap();
let source_agent_dir = tempfile::tempdir().unwrap();
let agent_dir = tempfile::tempdir().unwrap();
write_existing_pi_config(source_agent_dir.path());

View File

@@ -968,6 +968,7 @@ function App() {
setToastMessage,
onPushRejected: autoSync.handlePushRejected,
automaticVaultPaths: activeGitRepositoryPaths,
locale: appLocale,
manualVaultPath: gitSurfaces.commitRepositoryPath,
vaultPath: resolvedPath,
})

View File

@@ -1,21 +1,16 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type MutableRefObject, type PointerEvent as ReactPointerEvent } from 'react'
import { getAssetUrlsByImport } from '@tldraw/assets/imports.vite'
import { Dialog as DialogPrimitive } from 'radix-ui'
import {
Box,
DefaultKeyboardShortcutsDialog,
DefaultKeyboardShortcutsDialogContent,
TldrawUiButton,
TldrawUiButtonIcon,
TldrawUiMenuContextProvider,
Tldraw,
createTLStore,
defaultUserPreferences,
getSnapshot,
loadSnapshot,
react as reactToTldrawSignal,
useDialogs,
useTldrawUser,
useTranslation,
useValue,
type Editor,
type TLEventInfo,
type TLUiDialog,
@@ -35,7 +30,6 @@ function resolveTldrawAssetUrl(assetUrl: string | undefined): string {
}
const tldrawAssetUrls = getAssetUrlsByImport(resolveTldrawAssetUrl)
const tldrawUiComponents = { Dialogs: TolariaTldrawDialogs }
interface TldrawWhiteboardProps {
boardId: string
@@ -221,43 +215,7 @@ interface TolariaTldrawDialogProps {
}
const DIALOG_OPEN_DISMISS_GRACE_MS = 250
function isKeyboardShortcutsDialog(dialog: TLUiDialog): boolean {
return dialog.component === DefaultKeyboardShortcutsDialog
}
interface TolariaKeyboardShortcutsDialogProps {
onClose: () => void
}
function TolariaKeyboardShortcutsDialog({ onClose }: TolariaKeyboardShortcutsDialogProps) {
const msg = useTranslation()
return (
<>
<div className="tlui-dialog__header tlui-shortcuts-dialog__header">
<div className="tlui-dialog__header__title">{msg('shortcuts-dialog.title')}</div>
<div className="tlui-dialog__header__close">
<TldrawUiButton
type="icon"
aria-label={msg('ui.close')}
data-testid="dialog.close"
onClick={onClose}
title={msg('ui.close')}
>
<TldrawUiButtonIcon small icon="cross-2" />
</TldrawUiButton>
</div>
</div>
<div className="tlui-dialog__body tlui-shortcuts-dialog__body" tabIndex={0}>
<TldrawUiMenuContextProvider type="keyboard-shortcuts" sourceId="kbd">
<DefaultKeyboardShortcutsDialogContent />
</TldrawUiMenuContextProvider>
</div>
<div className="tlui-dialog__scrim" />
</>
)
}
let retainedTolariaTldrawDialogs: TLUiDialog[] = []
function useDeferredDialogOpen() {
const openedAtRef = useRef(0)
@@ -292,14 +250,12 @@ function shouldCloseFromOverlayClick(
interface TolariaTldrawDialogContentProps {
dialog: TLUiDialog
dialogTitle: string | undefined
mouseDownInsideContentRef: MutableRefObject<boolean>
onClose: () => void
}
function TolariaTldrawDialogContent({
dialog,
dialogTitle,
mouseDownInsideContentRef,
onClose,
}: TolariaTldrawDialogContentProps) {
@@ -314,14 +270,17 @@ function TolariaTldrawDialogContent({
dir="ltr"
className="tlui-dialog__content"
aria-describedby={undefined}
aria-label={dialogTitle}
role="dialog"
onKeyDown={(event) => {
if (event.key !== 'Escape') return
event.preventDefault()
mouseDownInsideContentRef.current = false
onClose()
}}
onMouseDown={() => { mouseDownInsideContentRef.current = true }}
onMouseUp={() => { mouseDownInsideContentRef.current = false }}
>
{isKeyboardShortcutsDialog(dialog)
? <TolariaKeyboardShortcutsDialog onClose={handleClose} />
: <ModalContent onClose={handleClose} />}
<ModalContent onClose={handleClose} />
</div>
)
}
@@ -329,57 +288,64 @@ function TolariaTldrawDialogContent({
const TolariaTldrawDialog = memo(function TolariaTldrawDialog({ dialog, onClose }: TolariaTldrawDialogProps) {
const mouseDownInsideContentRef = useRef(false)
const { openedAtRef, readyToOpen } = useDeferredDialogOpen()
const msg = useTranslation()
const dialogTitle = isKeyboardShortcutsDialog(dialog) ? msg('shortcuts-dialog.title') : undefined
const closeDialog = useCallback(() => {
const closeDialogFromBackground = useCallback(() => {
if (!canDismissDialog(openedAtRef.current)) return
onClose(dialog.id)
}, [dialog.id, onClose, openedAtRef])
const closeDialogNow = useCallback(() => { onClose(dialog.id) }, [dialog.id, onClose])
const handleOpenChange = useCallback((isOpen: boolean) => {
if (!isOpen) closeDialogNow()
}, [closeDialogNow])
if (!readyToOpen) return null
return (
<div
dir="ltr"
className="tlui-dialog__overlay"
onMouseDown={(event) => {
if (event.target === event.currentTarget) mouseDownInsideContentRef.current = false
}}
onClick={(event) => {
if (shouldCloseFromOverlayClick(event, dialog, mouseDownInsideContentRef.current)) closeDialog()
}}
>
<TolariaTldrawDialogContent
dialog={dialog}
dialogTitle={dialogTitle}
mouseDownInsideContentRef={mouseDownInsideContentRef}
onClose={closeDialogNow}
/>
</div>
<DialogPrimitive.Root open onOpenChange={handleOpenChange}>
<div
dir="ltr"
className="tlui-dialog__overlay"
onMouseDown={(event) => {
if (event.target === event.currentTarget) mouseDownInsideContentRef.current = false
}}
onClick={(event) => {
if (shouldCloseFromOverlayClick(event, dialog, mouseDownInsideContentRef.current)) {
closeDialogFromBackground()
}
}}
>
<TolariaTldrawDialogContent
dialog={dialog}
mouseDownInsideContentRef={mouseDownInsideContentRef}
onClose={closeDialogNow}
/>
</div>
</DialogPrimitive.Root>
)
})
function TolariaTldrawDialogs() {
const { dialogs, removeDialog } = useDialogs()
const [visibleDialogs, setVisibleDialogs] = useState<TLUiDialog[]>(() => dialogs.get())
const requestedDialogs = useValue('tolaria tldraw dialogs', () => dialogs.get(), [dialogs])
const [visibleDialogs, setVisibleDialogs] = useState<TLUiDialog[]>(() =>
retainedTolariaTldrawDialogs.length > 0 ? retainedTolariaTldrawDialogs : dialogs.get()
)
const closeVisibleDialog = useCallback((id: string) => {
setVisibleDialogs((current) => current.filter((dialog) => dialog.id !== id))
const nextDialogs = retainedTolariaTldrawDialogs.filter((dialog) => dialog.id !== id)
retainedTolariaTldrawDialogs = nextDialogs
setVisibleDialogs(nextDialogs)
removeDialog(id)
}, [removeDialog])
useEffect(() => reactToTldrawSignal(
'tolaria tldraw dialogs',
() => {
const nextDialogs = dialogs.get()
if (nextDialogs.length > 0) {
// tldraw clears the dialog atom while Radix closes the menu; keep the last requested dialog mounted locally.
setVisibleDialogs(nextDialogs)
}
}
), [dialogs])
useEffect(() => {
if (requestedDialogs.length === 0) return
// tldraw clears the dialog atom while Radix closes the menu; keep the last requested dialog mounted locally.
retainedTolariaTldrawDialogs = requestedDialogs
queueMicrotask(() => {
setVisibleDialogs(requestedDialogs)
})
}, [requestedDialogs])
return visibleDialogs.map((dialog) => (
<TolariaTldrawDialog
@@ -411,6 +377,7 @@ export function TldrawWhiteboard({
setUserPreferences: ignoreTldrawUserPreferencesUpdate,
userPreferences,
})
const tldrawUiComponents = useMemo(() => ({ Dialogs: TolariaTldrawDialogs }), [])
useEffect(() => {
onSnapshotChangeRef.current = onSnapshotChange

View File

@@ -2,6 +2,55 @@ import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useAutoGit } from './useAutoGit'
type AutoGitOptions = Parameters<typeof useAutoGit>[0]
function defaultAutoGitOptions(onCheckpoint: AutoGitOptions['onCheckpoint']): AutoGitOptions {
return {
enabled: true,
idleThresholdSeconds: 1,
inactiveThresholdSeconds: 1,
isGitVault: true,
hasPendingChanges: true,
hasUnsavedChanges: false,
onCheckpoint,
}
}
function renderAutoGit(overrides: Partial<AutoGitOptions> = {}) {
const onCheckpoint = overrides.onCheckpoint ?? vi.fn().mockResolvedValue(true)
const hook = renderHook(() => useAutoGit({
...defaultAutoGitOptions(onCheckpoint),
...overrides,
onCheckpoint,
}))
return { onCheckpoint, ...hook }
}
async function advanceAutoGitBy(ms: number) {
await act(async () => {
await vi.advanceTimersByTimeAsync(ms)
})
}
async function expectSingleCheckpointPerActivityBurst(
onCheckpoint: AutoGitOptions['onCheckpoint'],
recordActivity: () => void,
) {
await advanceAutoGitBy(1_000)
expect(onCheckpoint).toHaveBeenCalledTimes(1)
await advanceAutoGitBy(3_000)
expect(onCheckpoint).toHaveBeenCalledTimes(1)
act(() => {
recordActivity()
})
await advanceAutoGitBy(1_000)
expect(onCheckpoint).toHaveBeenCalledTimes(2)
}
describe('useAutoGit', () => {
let hasFocus = true
@@ -17,39 +66,23 @@ describe('useAutoGit', () => {
})
it('triggers an idle checkpoint after the configured threshold', async () => {
const onCheckpoint = vi.fn().mockResolvedValue(true)
renderHook(() => useAutoGit({
enabled: true,
const { onCheckpoint } = renderAutoGit({
idleThresholdSeconds: 3,
inactiveThresholdSeconds: 2,
isGitVault: true,
hasPendingChanges: true,
hasUnsavedChanges: false,
onCheckpoint,
}))
await act(async () => {
await vi.advanceTimersByTimeAsync(2_999)
})
await advanceAutoGitBy(2_999)
expect(onCheckpoint).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
await advanceAutoGitBy(1)
expect(onCheckpoint).toHaveBeenCalledWith('idle')
})
it('waits for the app to become inactive before triggering the inactive checkpoint', async () => {
const onCheckpoint = vi.fn().mockResolvedValue(true)
renderHook(() => useAutoGit({
enabled: true,
const { onCheckpoint } = renderAutoGit({
idleThresholdSeconds: 10,
inactiveThresholdSeconds: 2,
isGitVault: true,
hasPendingChanges: true,
hasUnsavedChanges: false,
onCheckpoint,
}))
})
hasFocus = false
await act(async () => {
@@ -61,53 +94,27 @@ describe('useAutoGit', () => {
})
it('does not trigger while the editor still has unsaved changes', async () => {
const onCheckpoint = vi.fn().mockResolvedValue(true)
renderHook(() => useAutoGit({
enabled: true,
idleThresholdSeconds: 1,
inactiveThresholdSeconds: 1,
isGitVault: true,
hasPendingChanges: true,
const { onCheckpoint } = renderAutoGit({
hasUnsavedChanges: true,
onCheckpoint,
}))
await act(async () => {
await vi.advanceTimersByTimeAsync(2_000)
})
await advanceAutoGitBy(2_000)
expect(onCheckpoint).not.toHaveBeenCalled()
})
it('only triggers once per activity burst until activity is recorded again', async () => {
const onCheckpoint = vi.fn().mockResolvedValue(true)
const { result } = renderHook(() => useAutoGit({
enabled: true,
idleThresholdSeconds: 1,
inactiveThresholdSeconds: 1,
isGitVault: true,
hasPendingChanges: true,
hasUnsavedChanges: false,
const { onCheckpoint, result } = renderAutoGit()
await expectSingleCheckpointPerActivityBurst(onCheckpoint, result.current.recordActivity)
})
it('does not retry a rejected checkpoint for the same activity burst', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const onCheckpoint = vi.fn().mockRejectedValue(new Error('Author identity unknown'))
const { result } = renderAutoGit({
onCheckpoint,
}))
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000)
})
expect(onCheckpoint).toHaveBeenCalledTimes(1)
await act(async () => {
await vi.advanceTimersByTimeAsync(3_000)
})
expect(onCheckpoint).toHaveBeenCalledTimes(1)
act(() => {
result.current.recordActivity()
})
await act(async () => {
await vi.advanceTimersByTimeAsync(1_000)
})
expect(onCheckpoint).toHaveBeenCalledTimes(2)
await expectSingleCheckpointPerActivityBurst(onCheckpoint, result.current.recordActivity)
expect(warnSpy).toHaveBeenCalledWith('[git] Auto-commit failed:', expect.any(Error))
})
})

View File

@@ -125,7 +125,10 @@ export function useAutoGit({
void onCheckpoint(trigger).then((didRun) => {
if (didRun) markTriggerAsHandled(lastTriggeredRef.current, trigger, lastActivityAt)
}).catch((err) => console.warn('[git] Auto-commit failed:', err))
}).catch((err) => {
markTriggerAsHandled(lastTriggeredRef.current, trigger, lastActivityAt)
console.warn('[git] Auto-commit failed:', err)
})
})
const updateAppActivity = useEffectEvent((active: boolean) => {

View File

@@ -151,6 +151,26 @@ describe('useCommitFlow', () => {
expect(setToastMessage).toHaveBeenCalledWith('Committed and pushed')
})
it('runAutomaticCheckpoint treats commit failures as handled after showing recovery feedback', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
mockInvokeFn.mockImplementation((command: string) => {
if (command === 'git_commit') return Promise.reject(new Error('Author identity unknown'))
throw new Error(`Unexpected command: ${command}`)
})
const { result } = renderCommitFlow()
let didHandleCheckpoint = false
await act(async () => {
didHandleCheckpoint = await result.current.runAutomaticCheckpoint()
})
expect(didHandleCheckpoint).toBe(true)
expect(setToastMessage).toHaveBeenCalledWith(
'Set a Git author before AutoGit can commit. Run git config --global user.name "Your Name" and git config --global user.email you@example.com.',
)
consoleSpy.mockRestore()
})
it('runAutomaticCheckpoint commits and pushes all active repositories', async () => {
const resolveRemoteStatusForVaultPath = vi.fn().mockResolvedValue({
branch: 'main',
@@ -191,6 +211,37 @@ describe('useCommitFlow', () => {
expect(setToastMessage).toHaveBeenCalledWith('AutoGit checkpointed 2 repositories')
})
it('runAutomaticCheckpoint treats multi-repository commit failures as handled', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const loadModifiedFilesForVaultPath = vi.fn((vaultPath: string) => Promise.resolve([{
path: `${vaultPath}/note.md`,
relativePath: 'note.md',
status: 'modified',
}]))
mockInvokeFn.mockImplementation((command: string) => {
if (command === 'git_commit') return Promise.reject(new Error('Please tell me who you are'))
throw new Error(`Unexpected command: ${command}`)
})
const { result } = renderCommitFlow({
automaticVaultPaths: ['/vault', '/work'],
loadModifiedFilesForVaultPath,
})
let didHandleCheckpoint = false
await act(async () => {
didHandleCheckpoint = await result.current.runAutomaticCheckpoint()
})
expect(didHandleCheckpoint).toBe(true)
expect(loadModifiedFilesForVaultPath).toHaveBeenCalledWith('/vault')
expect(loadModifiedFilesForVaultPath).toHaveBeenCalledWith('/work')
expect(setToastMessage).toHaveBeenCalledWith(
'Set a Git author before AutoGit can commit. Run git config --global user.name "Your Name" and git config --global user.email you@example.com.',
)
consoleSpy.mockRestore()
})
it('runAutomaticCheckpoint retries push-only when local commits are already ahead', async () => {
resolveRemoteStatusForVaultPath.mockResolvedValue({ branch: 'main', ahead: 2, behind: 0, hasRemote: true })
loadModifiedFilesForVaultPath.mockResolvedValue([])

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react'
import { invoke } from '@tauri-apps/api/core'
import type { GitPushResult, GitRemoteStatus, ModifiedFile } from '../types'
import { trackEvent } from '../lib/telemetry'
import { isTauri, mockInvoke } from '../mock-tauri'
import { generateAutomaticCommitMessage } from '../utils/automaticCommitMessage'
import { createTranslator, type AppLocale } from '../lib/i18n'
export type CommitMode = 'push' | 'local'
@@ -27,6 +28,7 @@ interface CommitFlowConfig {
setToastMessage: (msg: string | null) => void
onPushRejected?: () => void
automaticVaultPaths?: string[]
locale?: AppLocale
manualVaultPath?: string
vaultPath: string
}
@@ -80,6 +82,7 @@ type FinalizeCheckpointConfig = Pick<
CommitFlowConfig,
'loadModifiedFiles' | 'resolveRemoteStatusForVaultPath' | 'setToastMessage' | 'onPushRejected'
>
type Translator = ReturnType<typeof createTranslator>
interface FinalizeCheckpointArgs extends FinalizeCheckpointConfig {
result: CommitResult
@@ -132,10 +135,31 @@ function isPushRejected(result: CommitResult): boolean {
return result.status === 'rejected'
}
function formatCommitError(error: unknown): string {
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function isMissingGitAuthorIdentityError(message: string): boolean {
const normalized = message.toLowerCase()
return normalized.includes('author identity unknown')
|| normalized.includes('please tell me who you are')
|| normalized.includes('unable to auto-detect email address')
|| (normalized.includes('user.email') && normalized.includes('not set'))
|| (normalized.includes('user.name') && normalized.includes('not set'))
}
function formatCommitFailureToast(error: unknown, t: Translator): string {
const message = errorMessage(error)
if (isMissingGitAuthorIdentityError(message)) return t('git.toast.missingAuthor')
return t('git.toast.commitFailed', { error: message })
}
function formatAutoGitFailureToast(error: unknown, t: Translator): string {
const message = errorMessage(error)
if (isMissingGitAuthorIdentityError(message)) return t('git.toast.missingAuthor')
return t('git.toast.autoGitFailed', { error: message })
}
function shouldRetryPush(remoteStatus: GitRemoteStatus | null): boolean {
return remoteStatus?.hasRemote === true && remoteStatus.ahead > 0
}
@@ -227,15 +251,15 @@ async function checkpointRepository(
return { action, remoteStatus, result, status: 'executed', vaultPath }
}
function multiRepositoryCheckpointToast(results: RepositoryCheckpointResult[]): string {
function multiRepositoryCheckpointToast(results: RepositoryCheckpointResult[], t: Translator): string {
const executedCount = results.filter((result) => result.status === 'executed').length
const failedCount = results.filter((result) => result.status === 'failed').length
const rejectedCount = results.filter((result) => result.result && isPushRejected(result.result)).length
if (executedCount === 0) {
const firstError = results.find((result) => result.status === 'failed')?.error
return firstError
? `AutoGit failed: ${formatCommitError(firstError)}`
return firstError !== undefined
? formatAutoGitFailureToast(firstError, t)
: 'Nothing to commit or push'
}
@@ -331,6 +355,7 @@ async function checkpointRepositories(
async function runMultipleRepositoryCheckpoint(
targetVaultPaths: string[],
config: AutomaticCheckpointRunConfig,
t: Translator,
): Promise<boolean> {
const results = await checkpointRepositories(targetVaultPaths, config)
@@ -338,13 +363,13 @@ async function runMultipleRepositoryCheckpoint(
config.onPushRejected?.()
}
config.setToastMessage(multiRepositoryCheckpointToast(results))
config.setToastMessage(multiRepositoryCheckpointToast(results, t))
await runCheckpointRefresh({
loadModifiedFiles: config.loadModifiedFiles,
resolveRemoteStatusForVaultPath: config.resolveRemoteStatusForVaultPath,
vaultPaths: targetVaultPaths,
})
return results.some((result) => result.status === 'executed')
return results.some((result) => result.status === 'executed' || result.status === 'failed')
}
function useAutomaticCheckpointAction({
@@ -357,8 +382,10 @@ function useAutomaticCheckpointAction({
onPushRejected,
automaticVaultPaths,
vaultPath,
t,
}: CommitFlowConfig & {
checkpointInFlightRef: MutableRefObject<boolean>
t: Translator
}) {
return useCallback(async ({
savePendingBeforeCommit = false,
@@ -380,13 +407,13 @@ function useAutomaticCheckpointAction({
setToastMessage,
vaultPath,
}
return targetVaultPaths.length === 1
return await (targetVaultPaths.length === 1
? runSingleRepositoryCheckpoint(targetVaultPaths[0], runConfig)
: runMultipleRepositoryCheckpoint(targetVaultPaths, runConfig)
: runMultipleRepositoryCheckpoint(targetVaultPaths, runConfig, t))
} catch (err) {
console.error('Commit failed:', err)
setToastMessage(`Commit failed: ${formatCommitError(err)}`)
return false
setToastMessage(formatCommitFailureToast(err, t))
return true
} finally {
checkpointInFlightRef.current = false
}
@@ -399,6 +426,7 @@ function useAutomaticCheckpointAction({
resolveRemoteStatusForVaultPath,
savePending,
setToastMessage,
t,
vaultPath,
])
}
@@ -413,6 +441,7 @@ function useManualCommitPushAction({
manualVaultPath,
vaultPath,
setShowCommitDialog,
t,
}: Pick<
CommitFlowConfig,
| 'savePending'
@@ -425,6 +454,7 @@ function useManualCommitPushAction({
> & {
checkpointInFlightRef: MutableRefObject<boolean>
setShowCommitDialog: (open: boolean) => void
t: Translator
}) {
return useCallback(async (message: string) => {
setShowCommitDialog(false)
@@ -453,7 +483,7 @@ function useManualCommitPushAction({
})
} catch (err) {
console.error('Commit failed:', err)
setToastMessage(`Commit failed: ${formatCommitError(err)}`)
setToastMessage(formatCommitFailureToast(err, t))
} finally {
checkpointInFlightRef.current = false
}
@@ -466,6 +496,7 @@ function useManualCommitPushAction({
savePending,
setShowCommitDialog,
setToastMessage,
t,
vaultPath,
])
}
@@ -553,7 +584,7 @@ function useOpenCommitDialog({
setShowCommitDialog(true)
} catch (err) {
console.error('Commit dialog failed:', err)
setToastMessage(`Commit dialog failed: ${formatCommitError(err)}`)
setToastMessage(`Commit dialog failed: ${errorMessage(err)}`)
} finally {
dialogOpeningRef.current = false
setDialogOpening(false)
@@ -582,6 +613,7 @@ export function useCommitFlow({
setToastMessage,
onPushRejected,
automaticVaultPaths,
locale,
manualVaultPath,
vaultPath,
}: CommitFlowConfig) {
@@ -591,6 +623,7 @@ export function useCommitFlow({
const checkpointInFlightRef = useRef(false)
const dialogOpeningRef = useRef(false)
const commitModeVaultPathRef = useRef<string | null>(null)
const t = useMemo(() => createTranslator(locale), [locale])
const openCommitDialog = useOpenCommitDialog({
dialogOpeningRef,
@@ -616,6 +649,7 @@ export function useCommitFlow({
onPushRejected,
automaticVaultPaths,
vaultPath,
t,
})
const handleCommitPush = useManualCommitPushAction({
@@ -628,6 +662,7 @@ export function useCommitFlow({
manualVaultPath,
vaultPath,
setShowCommitDialog,
t,
})
useCommitModeRefresh({
commitModeVaultPathRef,

View File

@@ -60,6 +60,9 @@
"command.git.resolveConflicts": "Konflikte auflösen",
"command.git.viewChanges": "Ausstehende Änderungen anzeigen",
"git.repository.select": "Repository",
"git.toast.autoGitFailed": "AutoGit ist fehlgeschlagen: {error}",
"git.toast.commitFailed": "Commit fehlgeschlagen: {error}",
"git.toast.missingAuthor": "Legen Sie einen Git-Autor fest, bevor AutoGit einen Commit durchführen kann. Führen Sie git config --global user.name \"Ihr Name\" und git config --global user.email you@example.com aus.",
"command.view.editorOnly": "Nur Editor",
"command.view.editorNoteList": "Editor + Notizenliste",
"command.view.fullLayout": "Vollständiges Layout",

View File

@@ -60,6 +60,9 @@
"command.git.resolveConflicts": "Resolve Conflicts",
"command.git.viewChanges": "View Pending Changes",
"git.repository.select": "Repository",
"git.toast.autoGitFailed": "AutoGit failed: {error}",
"git.toast.commitFailed": "Commit failed: {error}",
"git.toast.missingAuthor": "Set a Git author before AutoGit can commit. Run git config --global user.name \"Your Name\" and git config --global user.email you@example.com.",
"command.view.editorOnly": "Editor Only",
"command.view.editorNoteList": "Editor + Note List",
"command.view.fullLayout": "Full Layout",

View File

@@ -60,6 +60,9 @@
"command.git.resolveConflicts": "Resolver conflictos",
"command.git.viewChanges": "Ver cambios pendientes",
"git.repository.select": "Repositorio",
"git.toast.autoGitFailed": "Error de AutoGit: {error}",
"git.toast.commitFailed": "Error al confirmar: {error}",
"git.toast.missingAuthor": "Establezca un autor de Git antes de que AutoGit pueda realizar la confirmación. Ejecute git config --global user.name \"Su nombre\" y git config --global user.email you@example.com.",
"command.view.editorOnly": "Solo editor",
"command.view.editorNoteList": "Editor + Lista de notas",
"command.view.fullLayout": "Diseño completo",

View File

@@ -60,6 +60,9 @@
"command.git.resolveConflicts": "Resolver conflictos",
"command.git.viewChanges": "Ver cambios pendientes",
"git.repository.select": "Repositorio",
"git.toast.autoGitFailed": "Error de AutoGit: {error}",
"git.toast.commitFailed": "Error al confirmar: {error}",
"git.toast.missingAuthor": "Establezca un autor de Git antes de que AutoGit pueda realizar la confirmación. Ejecute git config --global user.name \"Su nombre\" y git config --global user.email you@example.com.",
"command.view.editorOnly": "Solo editor",
"command.view.editorNoteList": "Editor + Lista de notas",
"command.view.fullLayout": "Diseño completo",

View File

@@ -60,6 +60,9 @@
"command.git.resolveConflicts": "Résoudre les conflits",
"command.git.viewChanges": "Afficher les modifications en attente",
"git.repository.select": "Dépôt",
"git.toast.autoGitFailed": "Échec d'AutoGit : {error}",
"git.toast.commitFailed": "Échec de la validation : {error}",
"git.toast.missingAuthor": "Définissez un auteur Git avant qu'AutoGit puisse valider. Exécutez git config --global user.name \"Votre nom\" et git config --global user.email you@example.com.",
"command.view.editorOnly": "Éditeur uniquement",
"command.view.editorNoteList": "Éditeur + liste de notes",
"command.view.fullLayout": "Mise en page complète",

View File

@@ -60,6 +60,9 @@
"command.git.resolveConflicts": "Risolvi conflitti",
"command.git.viewChanges": "Visualizza modifiche in sospeso",
"git.repository.select": "Repository",
"git.toast.autoGitFailed": "AutoGit non riuscito: {error}",
"git.toast.commitFailed": "Commit non riuscito: {error}",
"git.toast.missingAuthor": "Imposta un autore Git prima che AutoGit possa eseguire il commit. Esegui git config --global user.name \"Il tuo nome\" e git config --global user.email you@example.com.",
"command.view.editorOnly": "Solo editor",
"command.view.editorNoteList": "Editor + Elenco note",
"command.view.fullLayout": "Layout completo",

View File

@@ -60,6 +60,9 @@
"command.git.resolveConflicts": "競合を解決",
"command.git.viewChanges": "保留中の変更を表示",
"git.repository.select": "リポジトリ",
"git.toast.autoGitFailed": "AutoGitに失敗しました{error}",
"git.toast.commitFailed": "コミットに失敗しました:{error}",
"git.toast.missingAuthor": "AutoGitがコミットできるようになる前に、Gitの作成者を設定してください。git config --global user.name \"Your Name\" と git config --global user.email you@example.com を実行してください。",
"command.view.editorOnly": "エディターのみ",
"command.view.editorNoteList": "エディター + メモリスト",
"command.view.fullLayout": "フルレイアウト",

View File

@@ -60,6 +60,9 @@
"command.git.resolveConflicts": "충돌 해결",
"command.git.viewChanges": "보류 중인 변경 사항 보기",
"git.repository.select": "저장소",
"git.toast.autoGitFailed": "AutoGit 실패: {error}",
"git.toast.commitFailed": "커밋 실패: {error}",
"git.toast.missingAuthor": "AutoGit이 커밋하기 전에 Git 작성자를 설정하세요. git config --global user.name \"Your Name\" 및 git config --global user.email you@example.com을 실행하세요.",
"command.view.editorOnly": "편집기만",
"command.view.editorNoteList": "편집기 + 노트 목록",
"command.view.fullLayout": "전체 레이아웃",

View File

@@ -60,6 +60,9 @@
"command.git.resolveConflicts": "Rozwiąż konflikty",
"command.git.viewChanges": "Pokaż oczekujące zmiany",
"git.repository.select": "Repozytorium",
"git.toast.autoGitFailed": "Błąd AutoGit: {error}",
"git.toast.commitFailed": "Zatwierdzenie nie powiodło się: {error}",
"git.toast.missingAuthor": "Ustaw autora Git, zanim AutoGit będzie mógł wykonać commit. Uruchom git config --global user.name \"Twoje imię\" i git config --global user.email you@example.com.",
"command.view.editorOnly": "Tylko edytor",
"command.view.editorNoteList": "Edytor + lista notatek",
"command.view.fullLayout": "Pełny układ",

View File

@@ -60,6 +60,9 @@
"command.git.resolveConflicts": "Resolver conflitos",
"command.git.viewChanges": "Exibir alterações pendentes",
"git.repository.select": "Repositório",
"git.toast.autoGitFailed": "Falha no AutoGit: {error}",
"git.toast.commitFailed": "Falha no commit: {error}",
"git.toast.missingAuthor": "Defina um autor Git antes que o AutoGit possa fazer o commit. Execute git config --global user.name \"Seu Nome\" e git config --global user.email you@example.com.",
"command.view.editorOnly": "Somente editor",
"command.view.editorNoteList": "Editor + Lista de notas",
"command.view.fullLayout": "Layout completo",

View File

@@ -60,6 +60,9 @@
"command.git.resolveConflicts": "Resolver conflitos",
"command.git.viewChanges": "Ver alterações pendentes",
"git.repository.select": "Repositório",
"git.toast.autoGitFailed": "Falha no AutoGit: {error}",
"git.toast.commitFailed": "Falha no commit: {error}",
"git.toast.missingAuthor": "Defina um autor Git antes de o AutoGit poder fazer o commit. Execute git config --global user.name \"O seu nome\" e git config --global user.email you@example.com.",
"command.view.editorOnly": "Apenas editor",
"command.view.editorNoteList": "Editor + Lista de notas",
"command.view.fullLayout": "Disposição completa",

View File

@@ -60,6 +60,9 @@
"command.git.resolveConflicts": "Разрешить конфликты",
"command.git.viewChanges": "Просмотреть ожидающие изменения",
"git.repository.select": "Репозиторий",
"git.toast.autoGitFailed": "Ошибка AutoGit: {error}",
"git.toast.commitFailed": "Ошибка коммита: {error}",
"git.toast.missingAuthor": "Укажите автора Git, прежде чем AutoGit сможет выполнить коммит. Выполните git config --global user.name \"Your Name\" и git config --global user.email you@example.com.",
"command.view.editorOnly": "Только редактор",
"command.view.editorNoteList": "Редактор + список заметок",
"command.view.fullLayout": "Полный макет",

View File

@@ -60,6 +60,9 @@
"command.git.resolveConflicts": "Giải quyết xung đột",
"command.git.viewChanges": "Xem các thay đổi đang chờ",
"git.repository.select": "Kho lưu trữ",
"git.toast.autoGitFailed": "AutoGit không thành công: {error}",
"git.toast.commitFailed": "Gửi không thành công: {error}",
"git.toast.missingAuthor": "Đặt tác giả Git trước khi AutoGit có thể commit. Chạy git config --global user.name \"Tên của bạn\" và git config --global user.email you@example.com.",
"command.view.editorOnly": "Chỉ trình soạn thảo",
"command.view.editorNoteList": "Trình soạn thảo + danh sách ghi chú",
"command.view.fullLayout": "Bố cục đầy đủ",

View File

@@ -60,6 +60,9 @@
"command.git.resolveConflicts": "解决冲突",
"command.git.viewChanges": "查看待处理更改",
"git.repository.select": "仓库",
"git.toast.autoGitFailed": "AutoGit 失败:{error}",
"git.toast.commitFailed": "提交失败:{error}",
"git.toast.missingAuthor": "设置 Git 作者后AutoGit 才能提交。运行 git config --global user.name \"Your Name\" 和 git config --global user.email you@example.com。",
"command.view.editorOnly": "仅编辑器",
"command.view.editorNoteList": "编辑器 + 笔记列表",
"command.view.fullLayout": "完整布局",

View File

@@ -60,6 +60,9 @@
"command.git.resolveConflicts": "解決衝突",
"command.git.viewChanges": "檢視待處理更改",
"git.repository.select": "儲存庫",
"git.toast.autoGitFailed": "AutoGit 失敗:{error}",
"git.toast.commitFailed": "提交失敗:{error}",
"git.toast.missingAuthor": "設定 Git 作者後AutoGit 才能提交。執行 git config --global user.name \"Your Name\" 和 git config --global user.email you@example.com。",
"command.view.editorOnly": "僅編輯器",
"command.view.editorNoteList": "編輯器 + 筆記列表",
"command.view.fullLayout": "完整佈局",

View File

@@ -145,7 +145,8 @@ describe('buildStableDownloadRedirectPage', () => {
expect(html).toContain('Tolaria Stable Download Unavailable')
expect(html).toContain('View release history')
expect(html).toContain('https://refactoringhq.github.io/tolaria/')
expect(html).toContain('https://tolaria.md/releases/')
expect(html).not.toContain('https://refactoringhq.github.io/tolaria/')
expect(html).not.toContain('DOWNLOAD_TARGETS')
})
})

View File

@@ -1,4 +1,4 @@
const RELEASE_HISTORY_URL = 'https://refactoringhq.github.io/tolaria/'
const RELEASE_HISTORY_URL = 'https://tolaria.md/releases/'
const DOWNLOAD_FRAME_NAME = 'tolaria-download-frame'
type StablePlatformKey =

View File

@@ -121,6 +121,52 @@ describe('buildReleaseHistoryPage', () => {
expect(html.indexOf('Tolaria Alpha 2026.4.20.10')).toBeLessThan(html.indexOf('Tolaria Alpha 2026.4.20.9'))
})
it('deduplicates equivalent stable calendar tags and keeps the richer notes', () => {
const html = buildReleaseHistoryPage([
{
assets: [
{
browser_download_url: 'https://example.com/Tolaria_2026.5.13_macOS_Silicon.dmg',
name: 'Tolaria_2026.5.13_macOS_Silicon.dmg',
},
],
body: '## What&apos;s Changed\n\n<ul><li></li></ul>',
body_html: '<h2>What&apos;s Changed</h2><ul><li></li></ul>',
html_url: 'https://github.com/refactoringhq/tolaria/releases/tag/stable-v2026.5.13',
name: 'Tolaria 2026.5.13',
prerelease: false,
published_at: '2026-05-13T09:30:44Z',
tag_name: 'stable-v2026.5.13',
},
{
assets: [
{
browser_download_url: 'https://example.com/Tolaria_2026.5.13_macOS_Silicon.dmg',
name: 'Tolaria_2026.5.13_macOS_Silicon.dmg',
},
],
body_html: [
'<h2>What&apos;s Changed</h2>',
'<h3>Features</h3>',
'<ul>',
'<li>Add AI visibility setting</li>',
'<li>Support mounted vault workspaces</li>',
'</ul>',
].join(''),
html_url: 'https://github.com/refactoringhq/tolaria/releases/tag/v2026-05-13',
name: 'Tolaria v2026-05-13',
prerelease: false,
published_at: '2026-05-13T09:21:30Z',
tag_name: 'v2026-05-13',
},
])
expect(html).toContain('Stable<span class="tab-count">1</span>')
expect(html).toContain('Tolaria v2026-05-13')
expect(html).toContain('Add AI visibility setting')
expect(html).not.toContain('Tolaria 2026.5.13')
})
it('filters draft releases and shows an empty state for channels without published builds', () => {
const html = buildReleaseHistoryPage([
{

View File

@@ -516,6 +516,10 @@ const RELEASE_CHANNEL_LABELS: Record<ReleaseChannel, string> = {
const RELEASE_CHANNEL_LABELS_BY_CHANNEL = new Map<ReleaseChannel, string>(
Object.entries(RELEASE_CHANNEL_LABELS) as Array<[ReleaseChannel, string]>,
)
const STABLE_TAG_DATE_PATTERNS = [
/^stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})$/,
/^v(\d{4})-(\d{1,2})-(\d{1,2})$/,
]
function escapeMarkupText(value: string): string {
return value
@@ -635,6 +639,71 @@ function readableNotesUrlForRelease(channel: ReleaseChannel, tagName: string): s
return `release-notes/${encodeURIComponent(tagName)}.md`
}
function normalizeStableTagDate(tagName: string): string | null {
for (const pattern of STABLE_TAG_DATE_PATTERNS) {
const match = tagName.match(pattern)
if (!match) continue
const [, year, month, day] = match
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`
}
return null
}
function stableReleaseDedupeKey(release: ReleaseEntry): string {
const stableDate = normalizeStableTagDate(release.tagName)
return stableDate === null ? `tag:${release.tagName}` : `date:${stableDate}`
}
function releaseNotesListItemCount(notesHtml: string): number {
return notesHtml.match(/<li\b/gi)?.length ?? 0
}
function releasePreferenceScore(release: ReleaseEntry): number {
return releaseNotesListItemCount(release.notesHtml) + release.downloads.length
}
function mergeReleaseDownloads(primary: ReleaseDownload[], secondary: ReleaseDownload[]): ReleaseDownload[] {
const seenUrls = new Set<string>()
const downloads: ReleaseDownload[] = []
for (const download of [...primary, ...secondary]) {
if (seenUrls.has(download.url)) continue
seenUrls.add(download.url)
downloads.push(download)
}
return downloads
}
function preferredRelease(left: ReleaseEntry, right: ReleaseEntry): ReleaseEntry {
const leftScore = releasePreferenceScore(left)
const rightScore = releasePreferenceScore(right)
const selectedRelease = rightScore !== leftScore
? (rightScore > leftScore ? right : left)
: (right.publishedTimestamp > left.publishedTimestamp ? right : left)
const fallbackRelease = selectedRelease === right ? left : right
return {
...selectedRelease,
downloads: mergeReleaseDownloads(selectedRelease.downloads, fallbackRelease.downloads),
}
}
function deduplicateStableReleases(releases: ReleaseEntry[]): ReleaseEntry[] {
const releasesByDate = new Map<string, ReleaseEntry>()
for (const release of releases) {
const key = stableReleaseDedupeKey(release)
const existingRelease = releasesByDate.get(key)
releasesByDate.set(key, existingRelease ? preferredRelease(existingRelease, release) : release)
}
return Array.from(releasesByDate.values())
}
function normalizeReleaseEntry(release: GitHubReleasePayload): [ReleaseChannel, ReleaseEntry] | null {
if (release.draft === true) return null
@@ -678,6 +747,8 @@ function collectReleaseSections(payload: unknown): ReleaseSections {
appendReleaseSection(sections, normalizedRelease)
}
sections.stable = deduplicateStableReleases(sections.stable)
for (const channel of ['stable', 'alpha'] as const) {
const section = Reflect.get(sections, channel) as ReleaseEntry[]
section.sort((left, right) => right.publishedTimestamp - left.publishedTimestamp)

View File

@@ -7,16 +7,19 @@ type MockHandler = (args?: Record<string, unknown>) => unknown
function installAutoGitMocks() {
type BrowserWindow = Window & typeof globalThis & {
__getDirtyPathCount?: () => number
__gitCommitAttempts?: number
__gitCommitMessages?: string[]
__gitPushCalls?: number
__mockHandlers?: Record<string, MockHandler>
__setMockAppActive?: (active: boolean) => void
__setMockGitCommitFailure?: (message: string | null) => void
}
const browserWindow = window as BrowserWindow
const dirtyPaths = new Set<string>()
let appActive = true
let ahead = 0
let gitCommitFailure: string | null = null
const createModifiedFiles = () => [...dirtyPaths].map((path) => ({
path,
@@ -40,6 +43,11 @@ function installAutoGitMocks() {
handlers.get_modified_files = () => createModifiedFiles()
handlers.git_commit = (args?: Record<string, unknown>) => {
browserWindow.__gitCommitAttempts = (browserWindow.__gitCommitAttempts ?? 0) + 1
if (gitCommitFailure) {
throw new Error(gitCommitFailure)
}
const message = typeof args?.message === 'string' ? args.message : ''
browserWindow.__gitCommitMessages?.push(message)
dirtyPaths.clear()
@@ -84,7 +92,11 @@ function installAutoGitMocks() {
browserWindow.__setMockAppActive = (active: boolean) => {
appActive = active
}
browserWindow.__setMockGitCommitFailure = (message: string | null) => {
gitCommitFailure = message
}
browserWindow.__getDirtyPathCount = () => dirtyPaths.size
browserWindow.__gitCommitAttempts = 0
browserWindow.__gitCommitMessages = []
browserWindow.__gitPushCalls = 0
@@ -114,7 +126,9 @@ async function openAutoGitSettings(page: Page) {
const settingsPanel = page.getByTestId('settings-panel')
await expect(settingsPanel).toBeVisible({ timeout: 5_000 })
await page.getByRole('switch', { name: 'AutoGit' }).click()
const autoGitSwitch = settingsPanel.getByRole('switch', { name: 'AutoGit' })
await expect(autoGitSwitch).toBeEnabled({ timeout: 5_000 })
await autoGitSwitch.click({ force: true })
await page.getByTestId('settings-autogit-idle-threshold').fill('2')
await page.getByTestId('settings-autogit-inactive-threshold').fill('2')
await page.getByTestId('settings-save').click()
@@ -133,6 +147,12 @@ async function expectCommitMessageCount(page: Page, expectedCount: number) {
).toBe(expectedCount)
}
async function expectCommitAttemptCount(page: Page, expectedCount: number) {
await expect.poll(async () =>
page.evaluate(() => (window as Window & { __gitCommitAttempts?: number }).__gitCommitAttempts ?? 0),
).toBe(expectedCount)
}
async function expectCommitMessage(page: Page, index: number, expectedMessage: string) {
await expect.poll(async () =>
page.evaluate(
@@ -166,13 +186,21 @@ async function setMockAppActive(page: Page, active: boolean) {
}, active)
}
async function triggerQuickCommit(page: Page) {
const commitButton = page.getByTestId('status-commit-push')
await commitButton.focus()
await page.keyboard.press('Enter')
async function setMockGitCommitFailure(page: Page, message: string | null) {
await page.evaluate((failureMessage) => {
;(window as Window & {
__setMockGitCommitFailure?: (message: string | null) => void
}).__setMockGitCommitFailure?.(failureMessage)
}, message)
}
test('@smoke AutoGit checkpoints on idle and inactive, and the bottom bar reuses the same message', async ({ page }) => {
async function triggerQuickCommit(page: Page) {
const commitButton = page.getByTestId('status-commit-push')
await expect(commitButton).toBeEnabled({ timeout: 5_000 })
await commitButton.click()
}
test('@smoke AutoGit checkpoints on idle, and the bottom bar reuses the same message', async ({ page }) => {
await page.addInitScript(installAutoGitMocks)
await page.goto('/')
await page.waitForLoadState('networkidle')
@@ -183,14 +211,43 @@ test('@smoke AutoGit checkpoints on idle and inactive, and the bottom bar reuses
await seedSavedChange(page)
await expectCheckpoint(page, 1)
await seedSavedChange(page)
await setMockAppActive(page, false)
await expectCheckpoint(page, 2)
await setMockAppActive(page, true)
await seedSavedChange(page)
await triggerQuickCommit(page)
await expectCheckpoint(page, 3)
await expectCheckpoint(page, 2)
await expect(page.locator('.fixed.bottom-8')).toContainText('Committed and pushed', { timeout: 5_000 })
})
test('AutoGit checkpoints when the app is inactive', async ({ page }) => {
await page.addInitScript(installAutoGitMocks)
await page.goto('/')
await page.waitForLoadState('networkidle')
await openAutoGitSettings(page)
await openFirstNote(page)
await setMockAppActive(page, false)
await seedSavedChange(page)
await expectCheckpoint(page, 1)
})
test('AutoGit does not retry a failed checkpoint until the next saved change', async ({ page }) => {
await page.addInitScript(installAutoGitMocks)
await page.goto('/')
await page.waitForLoadState('networkidle')
await openAutoGitSettings(page)
await openFirstNote(page)
await setMockGitCommitFailure(page, 'Author identity unknown')
await seedSavedChange(page)
await expectCommitAttemptCount(page, 1)
await expectDirtyPathCount(page, 1)
await expect(page.locator('.fixed.bottom-8')).toContainText('Set a Git author before AutoGit can commit', { timeout: 5_000 })
await page.waitForTimeout(3_500)
await expectCommitAttemptCount(page, 1)
await seedSavedChange(page)
await expectCommitAttemptCount(page, 2)
})

View File

@@ -172,6 +172,23 @@ async function expectTitleHeadingText(page: Page, title: string): Promise<void>
})
}
async function clickEditorChromeBelowLastBlock(page: Page): Promise<void> {
const point = await page.evaluate(() => {
const container = document.querySelector('.editor__blocknote-container')?.getBoundingClientRect()
const blocks = Array.from(document.querySelectorAll('.bn-block-outer'))
const lastBlock = blocks.at(-1)?.getBoundingClientRect()
if (!container || !lastBlock) return null
return {
x: Math.min(container.right - 24, Math.max(container.left + 24, lastBlock.left + 40)),
y: Math.min(container.bottom - 24, lastBlock.bottom + 18),
}
})
expect(point).not.toBeNull()
await page.mouse.click(point!.x, point!.y)
}
let tempVaultDir: string
test.beforeEach(async ({ page }, testInfo) => {
@@ -363,6 +380,42 @@ test('@smoke new-note H1 auto-rename preserves body typing and cursor while rena
await expect(errors).toEqual([])
})
test('@smoke fresh-note Enter stays stable after autosave and editor chrome clicks', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => {
errors.push(err.message)
})
const title = 'Enter Selection Guard'
const filename = 'enter-selection-guard.md'
const firstLine = 'First paragraph before chrome clicks.'
const secondLine = 'Second paragraph after editor chrome click.'
const thirdLine = 'Third paragraph after another chrome click.'
await createUntitledNote(page)
await page.keyboard.type(title, { delay: 35 })
await page.keyboard.press('Enter')
await page.keyboard.type(firstLine, { delay: 35 })
await expectActiveFilename(page, 'enter-selection-guard')
await expectRenamedFile({ vaultPath: tempVaultDir, filename })
await page.waitForTimeout(900)
await clickEditorChromeBelowLastBlock(page)
await page.keyboard.press('Enter')
await page.keyboard.type(secondLine, { delay: 35 })
await clickEditorChromeBelowLastBlock(page)
await page.keyboard.press('Enter')
await page.keyboard.type(thirdLine, { delay: 35 })
await expect(page.locator('.error-boundary')).toHaveCount(0)
await expectEditorFocused(page)
expect(errors).toEqual([])
await expectFileContentContains({ vaultPath: tempVaultDir, filename, text: firstLine })
await expectFileContentContains({ vaultPath: tempVaultDir, filename, text: secondLine })
await expectFileContentContains({ vaultPath: tempVaultDir, filename, text: thirdLine })
})
test('@smoke new-note H1 auto-rename does not recreate the untitled file when a buffered save lands after rename', async ({ page }) => {
const title = 'Late Save Guard'
const lateBody = 'Body typed right before rename'

View File

@@ -32,6 +32,7 @@ function isEditorTypingCrash(message: string): boolean {
message.includes('beforeinput') ||
message.includes('Block with ID') ||
message.includes('stale editor view') ||
message.includes('Maximum update depth') ||
message.includes('Cannot read properties') ||
message.includes('undefined is not an object') ||
message.includes('RangeError') ||
@@ -52,12 +53,40 @@ function trackEditorTypingCrashes(page: Page): string[] {
return messages
}
function slugifyTitle(title: string): string {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
}
async function openNote(page: Page, title: string): Promise<void> {
const noteList = page.getByTestId('note-list-container')
await noteList.getByText(title, { exact: true }).click()
await expect(page.locator('.bn-editor h1').first()).toHaveText(title, { timeout: 5_000 })
}
async function createUntitledNote(page: Page): Promise<void> {
await page.locator('body').click()
await triggerMenuCommand(page, 'file-new-note')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+(?:-\d+)?/i, {
timeout: 5_000,
})
const titleBlock = page.locator('.bn-block-content[data-content-type="heading"]').first()
await expect(titleBlock).toBeVisible({ timeout: 5_000 })
await titleBlock.click()
await expectEditorFocused(page)
}
async function expectActiveFilename(page: Page, filenameStem: string): Promise<void> {
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(filenameStem, { timeout: 10_000 })
}
async function expectEditorFocused(page: Page): Promise<void> {
await expect.poll(async () => page.evaluate(() => {
const active = document.activeElement as HTMLElement | null
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
}), { timeout: 5_000 }).toBe(true)
}
async function placeCaretAtEndOfBlock(page: Page, blockIndex: number): Promise<void> {
const block = page.locator('.bn-block-content').nth(blockIndex)
await expect(block).toBeVisible({ timeout: 5_000 })
@@ -222,6 +251,22 @@ async function reloadVault(page: Page): Promise<void> {
})
}
async function stubUpdatedPull(page: Page, updatedFile: string): Promise<void> {
await page.evaluate((filePath) => {
window.__mockHandlers!.git_pull = () => ({
status: 'updated',
message: 'Pulled 1 update from remote',
updatedFiles: [filePath],
conflictFiles: [],
})
}, updatedFile)
}
async function pullFromRemote(page: Page): Promise<void> {
await triggerMenuCommand(page, 'vault-pull')
await expect(page.getByText('Pulled 1 update(s) from remote')).toBeVisible({ timeout: 5_000 })
}
async function notePathForTitle(page: Page, target: NoteTitleTarget): Promise<string> {
const note = page
.getByTestId('note-list-container')
@@ -345,6 +390,39 @@ test('typing after current-note filesystem refresh stays usable', async ({ page
expect(crashes).toEqual([])
})
test('editing after create-note pull reload and note switch avoids React update loops', async ({ page }) => {
const crashes = trackEditorTypingCrashes(page)
const title = `Reload Loop Guard ${Date.now()}`
const filenameStem = slugifyTitle(title)
const createdNotePath = path.join(tempVaultDir, `${filenameStem}.md`)
const noteCPath = path.join(tempVaultDir, 'note', 'note-c.md')
const createdBody = `Created before reload loop ${Date.now()}`
const pulledMarker = `Unrelated pulled reload loop change ${Date.now()}`
const afterSwitchMarker = `typing after create pull reload ${Date.now()}`
await createUntitledNote(page)
await page.keyboard.type(title, { delay: 10 })
await page.keyboard.press('Enter')
await page.keyboard.type(createdBody, { delay: 10 })
await expectActiveFilename(page, filenameStem)
await expectNoteFileToContain(createdNotePath, createdBody)
fs.appendFileSync(noteCPath, `\n\n${pulledMarker}\n`, 'utf8')
await stubUpdatedPull(page, noteCPath)
await pullFromRemote(page)
await reloadVault(page)
await openNote(page, 'Alpha Project')
await openNote(page, title)
await placeCaretAtEndOfBlockContaining(page, { text: createdBody })
await page.keyboard.type(` ${afterSwitchMarker}`, { delay: 10 })
await expectNoteFileToContain(createdNotePath, afterSwitchMarker)
await expect(page.locator('.error-boundary')).toHaveCount(0)
await page.waitForTimeout(500)
expect(crashes).toEqual([])
})
test('checklist toggles after a rich-editor reload ignore stale checkbox events', async ({ page }) => {
const crashes = trackEditorTypingCrashes(page)
const noteBPath = path.join(tempVaultDir, 'note', 'note-b.md')

View File

@@ -226,6 +226,28 @@ test('embedded tldraw dialogs appear and release focus when closed', async ({ pa
await expectNoEditorNodeSelection(page)
})
test('embedded tldraw insert embed dialog opens without crashing the note', async ({ page }) => {
await openNote(page, 'Whiteboard Embed')
const whiteboard = page.locator('.tldraw-whiteboard')
await expect(whiteboard).toBeVisible({ timeout: 20_000 })
await page.getByTestId('main-menu.button').click()
await page.getByTestId('main-menu.insert-embed').click()
const embedDialog = page.locator('.tldraw-whiteboard .tlui-dialog__content')
await expect(embedDialog).toBeVisible({ timeout: 5_000 })
await expect(embedDialog).toContainText('Insert embed')
await expect(page.locator('.error-boundary')).toHaveCount(0)
const dialogBox = await embedDialog.boundingBox()
const boardBox = await whiteboard.boundingBox()
expect(dialogBox).not.toBeNull()
expect(boardBox).not.toBeNull()
expect(dialogBox!.x).toBeGreaterThanOrEqual(boardBox!.x)
expect(dialogBox!.x + dialogBox!.width).toBeLessThanOrEqual(boardBox!.x + boardBox!.width)
})
test(TAURI_CONTEXT_MENU_TEST, async ({ page }) => {
await openNote(page, 'Whiteboard Embed')