Compare commits
11 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9be428aad3 | ||
|
|
b2d23a8559 | ||
|
|
d6ad2ff836 | ||
|
|
6ac8f822ff | ||
|
|
b0c8eff6c7 | ||
|
|
ae7a468310 | ||
|
|
84a993b948 | ||
|
|
2a5ebcf73d | ||
|
|
09978cf8e5 | ||
|
|
0e981228e2 | ||
|
|
b77ebd2a02 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
21
release-notes/v2026-05-12.md
Normal file
21
release-notes/v2026-05-12.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
78
src-tauri/src/mcp/subprocess.rs
Normal file
78
src-tauri/src/mcp/subprocess.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -968,6 +968,7 @@ function App() {
|
||||
setToastMessage,
|
||||
onPushRejected: autoSync.handlePushRejected,
|
||||
automaticVaultPaths: activeGitRepositoryPaths,
|
||||
locale: appLocale,
|
||||
manualVaultPath: gitSurfaces.commitRepositoryPath,
|
||||
vaultPath: resolvedPath,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "フルレイアウト",
|
||||
|
||||
@@ -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": "전체 레이아웃",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Полный макет",
|
||||
|
||||
@@ -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 đủ",
|
||||
|
||||
@@ -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": "完整布局",
|
||||
|
||||
@@ -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": "完整佈局",
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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's Changed\n\n<ul><li></li></ul>',
|
||||
body_html: '<h2>What'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'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([
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user