2026-02-22 13:33:32 +01:00
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
use std::fs;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
2026-05-03 16:15:03 +02:00
|
|
|
use crate::ai_models::{normalize_ai_model_providers, AiModelProvider};
|
|
|
|
|
|
2026-04-12 01:35:34 +02:00
|
|
|
const APP_CONFIG_DIR: &str = "com.tolaria.app";
|
|
|
|
|
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
|
2026-05-18 13:55:43 +02:00
|
|
|
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] =
|
|
|
|
|
&["claude_code", "codex", "opencode", "pi", "gemini", "kiro"];
|
2026-04-28 08:55:40 +02:00
|
|
|
pub const DEFAULT_HIDE_GITIGNORED_FILES: bool = true;
|
2026-04-29 19:26:24 +02:00
|
|
|
const SUPPORTED_NOTE_WIDTH_MODES: &[&str] = &["normal", "wide"];
|
2026-05-11 13:46:18 +02:00
|
|
|
const SUPPORTED_DATE_DISPLAY_FORMATS: &[&str] = &["us", "european", "friendly", "iso"];
|
2026-04-30 12:45:10 +02:00
|
|
|
const SUPPORTED_UI_LANGUAGE_ALIASES: &[(&str, &str)] = &[
|
|
|
|
|
("en", "en"),
|
|
|
|
|
("en-us", "en"),
|
|
|
|
|
("en-gb", "en"),
|
|
|
|
|
("en-ca", "en"),
|
|
|
|
|
("en-au", "en"),
|
|
|
|
|
("it", "it-IT"),
|
|
|
|
|
("it-it", "it-IT"),
|
|
|
|
|
("fr", "fr-FR"),
|
|
|
|
|
("fr-fr", "fr-FR"),
|
|
|
|
|
("de", "de-DE"),
|
|
|
|
|
("de-de", "de-DE"),
|
|
|
|
|
("ru", "ru-RU"),
|
|
|
|
|
("ru-ru", "ru-RU"),
|
|
|
|
|
("es-es", "es-ES"),
|
|
|
|
|
("pt-br", "pt-BR"),
|
|
|
|
|
("pt-pt", "pt-PT"),
|
|
|
|
|
("es-419", "es-419"),
|
|
|
|
|
("es-ar", "es-419"),
|
|
|
|
|
("es-bo", "es-419"),
|
|
|
|
|
("es-cl", "es-419"),
|
|
|
|
|
("es-co", "es-419"),
|
|
|
|
|
("es-cr", "es-419"),
|
|
|
|
|
("es-cu", "es-419"),
|
|
|
|
|
("es-do", "es-419"),
|
|
|
|
|
("es-ec", "es-419"),
|
|
|
|
|
("es-gt", "es-419"),
|
|
|
|
|
("es-hn", "es-419"),
|
|
|
|
|
("es-mx", "es-419"),
|
|
|
|
|
("es-ni", "es-419"),
|
|
|
|
|
("es-pa", "es-419"),
|
|
|
|
|
("es-pe", "es-419"),
|
|
|
|
|
("es-pr", "es-419"),
|
|
|
|
|
("es-py", "es-419"),
|
|
|
|
|
("es-sv", "es-419"),
|
|
|
|
|
("es-us", "es-419"),
|
|
|
|
|
("es-uy", "es-419"),
|
|
|
|
|
("es-ve", "es-419"),
|
2026-04-30 13:20:03 +02:00
|
|
|
("zh", "zh-CN"),
|
|
|
|
|
("zh-cn", "zh-CN"),
|
|
|
|
|
("zh-hans", "zh-CN"),
|
|
|
|
|
("zh-sg", "zh-CN"),
|
2026-04-30 12:45:10 +02:00
|
|
|
("zh-tw", "zh-TW"),
|
|
|
|
|
("zh-hant", "zh-TW"),
|
|
|
|
|
("zh-hk", "zh-TW"),
|
|
|
|
|
("zh-mo", "zh-TW"),
|
|
|
|
|
("ja", "ja-JP"),
|
|
|
|
|
("ja-jp", "ja-JP"),
|
|
|
|
|
("ko", "ko-KR"),
|
|
|
|
|
("ko-kr", "ko-KR"),
|
|
|
|
|
("vi", "vi"),
|
|
|
|
|
("vi-vn", "vi"),
|
|
|
|
|
];
|
2026-04-12 01:35:34 +02:00
|
|
|
|
2026-05-26 19:15:05 +02:00
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
|
|
|
|
pub struct AiWorkspaceConversationSetting {
|
|
|
|
|
pub archived: Option<bool>,
|
|
|
|
|
pub id: String,
|
|
|
|
|
pub target_id: Option<String>,
|
|
|
|
|
pub title: String,
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 22:26:07 +02:00
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
2026-02-22 13:33:32 +01:00
|
|
|
pub struct Settings {
|
feat: auto-pull vault changes from Git (background sync + conflict handling) (#79)
* feat: add auto-pull vault sync with conflict handling
- Add git_pull, has_remote, get_conflict_files to Rust backend
- Add GitPullResult type and auto_pull_interval_minutes to Settings
- Create useAutoSync hook (pull on launch, focus, periodic interval)
- Update StatusBar with real sync status indicator
- Add pull interval setting to SettingsPanel
- Add mock handler for git_pull
- Update existing tests for new Settings field
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: add tests for git pull, settings, and useAutoSync hook
- Add Rust tests: has_remote, git_pull (no_remote, up_to_date, updated),
get_conflict_files, parse_updated_files, GitPullResult serialization
- Add frontend tests: useAutoSync (mount pull, focus pull, conflict,
error, manual trigger, concurrent prevention, no_remote)
- Fix existing settings tests for new auto_pull_interval_minutes field
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* design: add auto-pull-vault wireframes
Frames showing: sync idle, syncing, conflict indicator,
settings sync section, and conflict toast notification.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add auto_pull_interval_minutes to all Settings literals
Fix build error and test fixtures missing the new field.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: cargo fmt
* ci: re-trigger CI after flaky test
* ci: retrigger after disk space cleanup
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 01:55:16 +01:00
|
|
|
pub auto_pull_interval_minutes: Option<u32>,
|
2026-05-16 12:13:20 +02:00
|
|
|
pub git_enabled: Option<bool>,
|
2026-04-16 23:38:30 +02:00
|
|
|
pub autogit_enabled: Option<bool>,
|
|
|
|
|
pub autogit_idle_threshold_seconds: Option<u32>,
|
|
|
|
|
pub autogit_inactive_threshold_seconds: Option<u32>,
|
2026-04-24 15:04:08 +09:00
|
|
|
pub auto_advance_inbox_after_organize: Option<bool>,
|
2026-03-25 16:05:13 +01:00
|
|
|
pub telemetry_consent: Option<bool>,
|
|
|
|
|
pub crash_reporting_enabled: Option<bool>,
|
|
|
|
|
pub analytics_enabled: Option<bool>,
|
|
|
|
|
pub anonymous_id: Option<String>,
|
2026-04-03 21:22:28 +02:00
|
|
|
pub release_channel: Option<String>,
|
2026-04-24 22:26:07 +02:00
|
|
|
pub theme_mode: Option<String>,
|
2026-04-26 08:18:47 +02:00
|
|
|
pub ui_language: Option<String>,
|
2026-05-11 13:46:18 +02:00
|
|
|
pub date_display_format: Option<String>,
|
2026-04-29 19:26:24 +02:00
|
|
|
pub note_width_mode: Option<String>,
|
2026-05-04 21:54:06 +02:00
|
|
|
pub sidebar_type_pluralization_enabled: Option<bool>,
|
2026-04-16 12:18:11 +02:00
|
|
|
pub initial_h1_auto_rename_enabled: Option<bool>,
|
2026-05-12 22:30:24 +02:00
|
|
|
pub ai_features_enabled: Option<bool>,
|
2026-04-13 19:37:59 +02:00
|
|
|
pub default_ai_agent: Option<String>,
|
2026-05-03 16:15:03 +02:00
|
|
|
pub default_ai_target: Option<String>,
|
|
|
|
|
pub ai_model_providers: Option<Vec<AiModelProvider>>,
|
2026-05-26 19:15:05 +02:00
|
|
|
pub ai_workspace_conversations: Option<Vec<AiWorkspaceConversationSetting>>,
|
2026-04-28 08:55:40 +02:00
|
|
|
pub hide_gitignored_files: Option<bool>,
|
2026-04-30 02:02:59 +02:00
|
|
|
pub all_notes_show_pdfs: Option<bool>,
|
|
|
|
|
pub all_notes_show_images: Option<bool>,
|
|
|
|
|
pub all_notes_show_unsupported: Option<bool>,
|
2026-05-11 16:37:06 +02:00
|
|
|
pub multi_workspace_enabled: Option<bool>,
|
2026-02-22 13:33:32 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 22:14:53 +02:00
|
|
|
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
|
|
|
|
value
|
|
|
|
|
.map(|candidate| candidate.trim().to_string())
|
|
|
|
|
.filter(|candidate| !candidate.is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-16 23:38:30 +02:00
|
|
|
fn normalize_optional_positive_u32(value: Option<u32>) -> Option<u32> {
|
|
|
|
|
value.filter(|candidate| *candidate > 0)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 22:14:53 +02:00
|
|
|
pub fn normalize_release_channel(value: Option<&str>) -> Option<String> {
|
|
|
|
|
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
|
|
|
|
|
Some(channel) if channel == "alpha" => Some(channel),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn effective_release_channel(value: Option<&str>) -> &'static str {
|
|
|
|
|
if normalize_release_channel(value).is_some() {
|
|
|
|
|
"alpha"
|
|
|
|
|
} else {
|
|
|
|
|
"stable"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 19:37:59 +02:00
|
|
|
pub fn normalize_default_ai_agent(value: Option<&str>) -> Option<String> {
|
|
|
|
|
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
|
2026-04-28 01:26:21 +02:00
|
|
|
Some(agent) if SUPPORTED_DEFAULT_AI_AGENTS.contains(&agent.as_str()) => Some(agent),
|
2026-04-13 19:37:59 +02:00
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 22:26:07 +02:00
|
|
|
pub fn normalize_theme_mode(value: Option<&str>) -> Option<String> {
|
|
|
|
|
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
|
2026-05-05 15:00:24 +02:00
|
|
|
Some(mode) if mode == "light" || mode == "dark" || mode == "system" => Some(mode),
|
2026-04-24 22:26:07 +02:00
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 19:26:24 +02:00
|
|
|
pub fn normalize_note_width_mode(value: Option<&str>) -> Option<String> {
|
|
|
|
|
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
|
|
|
|
|
Some(mode) if SUPPORTED_NOTE_WIDTH_MODES.contains(&mode.as_str()) => Some(mode),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 13:46:18 +02:00
|
|
|
pub fn normalize_date_display_format(value: Option<&str>) -> Option<String> {
|
|
|
|
|
match value.map(|candidate| candidate.trim().to_ascii_lowercase()) {
|
|
|
|
|
Some(format) if SUPPORTED_DATE_DISPLAY_FORMATS.contains(&format.as_str()) => Some(format),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 08:55:40 +02:00
|
|
|
pub fn should_hide_gitignored_files(settings: &Settings) -> bool {
|
|
|
|
|
settings
|
|
|
|
|
.hide_gitignored_files
|
|
|
|
|
.unwrap_or(DEFAULT_HIDE_GITIGNORED_FILES)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn hide_gitignored_files_enabled() -> bool {
|
|
|
|
|
get_settings()
|
|
|
|
|
.map(|settings| should_hide_gitignored_files(&settings))
|
|
|
|
|
.unwrap_or(DEFAULT_HIDE_GITIGNORED_FILES)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 08:18:47 +02:00
|
|
|
fn canonical_language_code(value: &str) -> Option<String> {
|
|
|
|
|
let code = value.trim().replace('_', "-").to_ascii_lowercase();
|
|
|
|
|
if code.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn normalize_ui_language(value: Option<&str>) -> Option<String> {
|
|
|
|
|
let language = canonical_language_code(value?)?;
|
2026-04-30 12:45:10 +02:00
|
|
|
SUPPORTED_UI_LANGUAGE_ALIASES
|
|
|
|
|
.iter()
|
|
|
|
|
.find_map(|(alias, canonical)| (*alias == language).then(|| (*canonical).to_string()))
|
2026-04-26 08:18:47 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 22:14:53 +02:00
|
|
|
fn normalize_settings(settings: Settings) -> Settings {
|
|
|
|
|
Settings {
|
|
|
|
|
auto_pull_interval_minutes: settings.auto_pull_interval_minutes,
|
2026-05-16 12:13:20 +02:00
|
|
|
git_enabled: settings.git_enabled,
|
2026-04-16 23:38:30 +02:00
|
|
|
autogit_enabled: settings.autogit_enabled,
|
|
|
|
|
autogit_idle_threshold_seconds: normalize_optional_positive_u32(
|
|
|
|
|
settings.autogit_idle_threshold_seconds,
|
|
|
|
|
),
|
|
|
|
|
autogit_inactive_threshold_seconds: normalize_optional_positive_u32(
|
|
|
|
|
settings.autogit_inactive_threshold_seconds,
|
|
|
|
|
),
|
2026-04-24 15:04:08 +09:00
|
|
|
auto_advance_inbox_after_organize: settings.auto_advance_inbox_after_organize,
|
2026-04-12 22:14:53 +02:00
|
|
|
telemetry_consent: settings.telemetry_consent,
|
|
|
|
|
crash_reporting_enabled: settings.crash_reporting_enabled,
|
|
|
|
|
analytics_enabled: settings.analytics_enabled,
|
|
|
|
|
anonymous_id: normalize_optional_string(settings.anonymous_id),
|
|
|
|
|
release_channel: normalize_release_channel(settings.release_channel.as_deref()),
|
2026-04-24 22:26:07 +02:00
|
|
|
theme_mode: normalize_theme_mode(settings.theme_mode.as_deref()),
|
2026-04-26 08:18:47 +02:00
|
|
|
ui_language: normalize_ui_language(settings.ui_language.as_deref()),
|
2026-05-11 13:46:18 +02:00
|
|
|
date_display_format: normalize_date_display_format(settings.date_display_format.as_deref()),
|
2026-04-29 19:26:24 +02:00
|
|
|
note_width_mode: normalize_note_width_mode(settings.note_width_mode.as_deref()),
|
2026-05-04 21:54:06 +02:00
|
|
|
sidebar_type_pluralization_enabled: settings.sidebar_type_pluralization_enabled,
|
2026-04-16 12:18:11 +02:00
|
|
|
initial_h1_auto_rename_enabled: settings.initial_h1_auto_rename_enabled,
|
2026-05-12 22:30:24 +02:00
|
|
|
ai_features_enabled: settings.ai_features_enabled,
|
2026-04-13 19:37:59 +02:00
|
|
|
default_ai_agent: normalize_default_ai_agent(settings.default_ai_agent.as_deref()),
|
2026-05-03 16:15:03 +02:00
|
|
|
default_ai_target: normalize_optional_string(settings.default_ai_target),
|
|
|
|
|
ai_model_providers: normalize_ai_model_providers(settings.ai_model_providers),
|
2026-05-26 19:15:05 +02:00
|
|
|
ai_workspace_conversations: normalize_ai_workspace_conversations(
|
|
|
|
|
settings.ai_workspace_conversations,
|
|
|
|
|
),
|
2026-04-28 08:55:40 +02:00
|
|
|
hide_gitignored_files: settings.hide_gitignored_files,
|
2026-04-30 02:02:59 +02:00
|
|
|
all_notes_show_pdfs: settings.all_notes_show_pdfs,
|
|
|
|
|
all_notes_show_images: settings.all_notes_show_images,
|
|
|
|
|
all_notes_show_unsupported: settings.all_notes_show_unsupported,
|
2026-05-11 16:37:06 +02:00
|
|
|
multi_workspace_enabled: settings.multi_workspace_enabled,
|
2026-04-12 22:14:53 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 19:15:05 +02:00
|
|
|
fn normalize_ai_workspace_conversations(
|
|
|
|
|
conversations: Option<Vec<AiWorkspaceConversationSetting>>,
|
|
|
|
|
) -> Option<Vec<AiWorkspaceConversationSetting>> {
|
|
|
|
|
let normalized: Vec<AiWorkspaceConversationSetting> = conversations
|
|
|
|
|
.unwrap_or_default()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.filter_map(|conversation| {
|
|
|
|
|
let id = conversation.id.trim().to_string();
|
|
|
|
|
let title = conversation.title.trim().to_string();
|
|
|
|
|
if id.is_empty() || title.is_empty() {
|
|
|
|
|
return None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Some(AiWorkspaceConversationSetting {
|
|
|
|
|
archived: conversation.archived,
|
|
|
|
|
id,
|
|
|
|
|
target_id: normalize_optional_string(conversation.target_id),
|
|
|
|
|
title,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.take(100)
|
|
|
|
|
.collect();
|
|
|
|
|
|
|
|
|
|
if normalized.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
Some(normalized)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 01:35:34 +02:00
|
|
|
fn app_config_dir() -> Result<PathBuf, String> {
|
2026-04-12 01:41:26 +02:00
|
|
|
dirs::config_dir().ok_or_else(|| "Could not determine config directory".to_string())
|
2026-02-22 13:33:32 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-26 15:45:34 +02:00
|
|
|
pub(crate) fn preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
|
2026-04-12 01:35:34 +02:00
|
|
|
Ok(app_config_dir()?.join(APP_CONFIG_DIR).join(file_name))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resolve_existing_or_preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
|
|
|
|
|
let preferred = preferred_app_config_path(file_name)?;
|
|
|
|
|
if preferred.exists() {
|
|
|
|
|
return Ok(preferred);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 01:41:26 +02:00
|
|
|
let legacy = app_config_dir()?
|
|
|
|
|
.join(LEGACY_APP_CONFIG_DIR)
|
|
|
|
|
.join(file_name);
|
2026-04-12 01:35:34 +02:00
|
|
|
if legacy.exists() {
|
|
|
|
|
return Ok(legacy);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(preferred)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn settings_path() -> Result<PathBuf, String> {
|
|
|
|
|
resolve_existing_or_preferred_app_config_path("settings.json")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 18:04:01 +01:00
|
|
|
fn get_settings_at(path: &PathBuf) -> Result<Settings, String> {
|
2026-02-22 13:33:32 +01:00
|
|
|
if !path.exists() {
|
|
|
|
|
return Ok(Settings::default());
|
|
|
|
|
}
|
feat: auto-build, GitHub Release, and in-app updater (#14)
* ci: auto-release workflow on merge to main
Rewrite .github/workflows/release.yml to trigger on every push to main
instead of manual tag pushes. The workflow now:
- Computes version as 0.YYYYMMDD.GITHUB_RUN_NUMBER
- Builds aarch64-apple-darwin and x86_64-apple-darwin in parallel
- Merges them into a universal binary using lipo
- Creates a universal .dmg and signed updater tarball
- Generates latest.json with per-arch and universal platform entries
- Publishes a GitHub Release with auto-generated release notes
- Updates a GitHub Pages release history site (gh-pages branch)
Product decisions:
- Universal binary approach: copy arm64 .app as base, lipo the main
executable, keep everything else from arm64 (shared frameworks are
architecture-independent). This is the standard Tauri pattern.
- Per-arch updater tarballs are also uploaded so the Tauri updater can
download the correct arch-specific build (smaller download).
- Release notes are auto-generated from git log since last tag.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* ci: github pages with release history
Use peaceiris/actions-gh-pages@v4 to deploy a release history site.
The page fetches releases.json (also deployed) and renders each release
with date, notes, and download links for .dmg files.
This handles the gh-pages branch creation automatically on first run.
The page is available at https://refactoringhq.github.io/laputa-app/
Product decision: used fetch() to load releases.json at runtime instead
of inlining it, which is cleaner and avoids shell escaping issues with
release note content. The releases.json is deployed alongside index.html.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: in-app update notification UI
Replace the old window.confirm updater with a proper React-based
update notification system:
- useUpdater hook now exposes state machine (idle → available →
downloading → ready) and actions (startDownload, openReleaseNotes,
dismiss)
- UpdateBanner component renders at the top of the app shell:
- "Available" state: shows version, Release Notes link, Update Now
button, dismiss X
- "Downloading" state: animated spinner, progress bar with percentage
- "Ready" state: Restart Now button to apply the update
- Silently checks on startup after 3s delay; fails silently on
network errors or 404
- Release Notes link opens the GitHub Pages release history site
Product decisions:
- Banner at top of app (not a modal) — non-intrusive, visible but
not blocking. User can dismiss and continue working.
- Progress bar shows during download so user knows it's working.
- Separate "Restart Now" state after download so user controls when
the app restarts (they may have unsaved work).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: updater component tests
Rewrite useUpdater hook tests and add UpdateBanner component tests:
Hook tests (10 cases):
- Starts in idle state
- Does nothing when not in Tauri
- Checks for updates after 3s delay
- Stays idle when no update available
- Transitions to available when update found
- Handles missing release body gracefully
- Stays idle on network error (fails silently)
- Dismiss returns to idle
- openReleaseNotes opens correct URL
- startDownload transitions through downloading to ready
Component tests (10 cases):
- Renders nothing when idle
- Renders nothing on error
- Shows version and buttons when available
- Update Now calls startDownload
- Release Notes calls openReleaseNotes
- Dismiss button works
- Shows progress bar during download
- Shows 0% at start of download
- Shows restart button when ready
- Restart button calls restartApp
All 457 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* design: auto-build-release wireframes
Copy ui-design.pen as base. Frames to be added for:
1. Update notification banner (visible state) — horizontal bar at
top of app shell with version text, Release Notes link, Update
Now button, and dismiss X
2. Update download progress state — spinner icon, progress bar with
percentage, downloading text
3. "Restart to apply" state — green accent, version text, Restart
Now button
Note: Pencil editor was not available during this session. The base
design file is committed; frames will be added when the editor is
accessible. The implemented component (UpdateBanner.tsx) serves as
the source of truth for the design.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update ARCHITECTURE.md with release/update system
Add comprehensive documentation for:
- Release pipeline (4-phase workflow: version → build → release → pages)
- Versioning scheme (0.YYYYMMDD.RUN_NUMBER)
- Universal binary strategy (lipo merge)
- Updater endpoint and latest.json manifest
- In-app update UI state machine
- GitHub Pages release history site
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: rustfmt formatting
* fix: rustfmt build.rs
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:50:36 +01:00
|
|
|
let content =
|
|
|
|
|
fs::read_to_string(path).map_err(|e| format!("Failed to read settings: {}", e))?;
|
2026-04-12 22:14:53 +02:00
|
|
|
let settings =
|
|
|
|
|
serde_json::from_str(&content).map_err(|e| format!("Failed to parse settings: {}", e))?;
|
|
|
|
|
Ok(normalize_settings(settings))
|
2026-02-22 13:33:32 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 18:04:01 +01:00
|
|
|
fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> {
|
2026-02-22 13:33:32 +01:00
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
fs::create_dir_all(parent)
|
|
|
|
|
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 22:14:53 +02:00
|
|
|
let cleaned = normalize_settings(settings);
|
2026-02-22 13:33:32 +01:00
|
|
|
|
|
|
|
|
let json = serde_json::to_string_pretty(&cleaned)
|
|
|
|
|
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
|
feat: auto-build, GitHub Release, and in-app updater (#14)
* ci: auto-release workflow on merge to main
Rewrite .github/workflows/release.yml to trigger on every push to main
instead of manual tag pushes. The workflow now:
- Computes version as 0.YYYYMMDD.GITHUB_RUN_NUMBER
- Builds aarch64-apple-darwin and x86_64-apple-darwin in parallel
- Merges them into a universal binary using lipo
- Creates a universal .dmg and signed updater tarball
- Generates latest.json with per-arch and universal platform entries
- Publishes a GitHub Release with auto-generated release notes
- Updates a GitHub Pages release history site (gh-pages branch)
Product decisions:
- Universal binary approach: copy arm64 .app as base, lipo the main
executable, keep everything else from arm64 (shared frameworks are
architecture-independent). This is the standard Tauri pattern.
- Per-arch updater tarballs are also uploaded so the Tauri updater can
download the correct arch-specific build (smaller download).
- Release notes are auto-generated from git log since last tag.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* ci: github pages with release history
Use peaceiris/actions-gh-pages@v4 to deploy a release history site.
The page fetches releases.json (also deployed) and renders each release
with date, notes, and download links for .dmg files.
This handles the gh-pages branch creation automatically on first run.
The page is available at https://refactoringhq.github.io/laputa-app/
Product decision: used fetch() to load releases.json at runtime instead
of inlining it, which is cleaner and avoids shell escaping issues with
release note content. The releases.json is deployed alongside index.html.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: in-app update notification UI
Replace the old window.confirm updater with a proper React-based
update notification system:
- useUpdater hook now exposes state machine (idle → available →
downloading → ready) and actions (startDownload, openReleaseNotes,
dismiss)
- UpdateBanner component renders at the top of the app shell:
- "Available" state: shows version, Release Notes link, Update Now
button, dismiss X
- "Downloading" state: animated spinner, progress bar with percentage
- "Ready" state: Restart Now button to apply the update
- Silently checks on startup after 3s delay; fails silently on
network errors or 404
- Release Notes link opens the GitHub Pages release history site
Product decisions:
- Banner at top of app (not a modal) — non-intrusive, visible but
not blocking. User can dismiss and continue working.
- Progress bar shows during download so user knows it's working.
- Separate "Restart Now" state after download so user controls when
the app restarts (they may have unsaved work).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: updater component tests
Rewrite useUpdater hook tests and add UpdateBanner component tests:
Hook tests (10 cases):
- Starts in idle state
- Does nothing when not in Tauri
- Checks for updates after 3s delay
- Stays idle when no update available
- Transitions to available when update found
- Handles missing release body gracefully
- Stays idle on network error (fails silently)
- Dismiss returns to idle
- openReleaseNotes opens correct URL
- startDownload transitions through downloading to ready
Component tests (10 cases):
- Renders nothing when idle
- Renders nothing on error
- Shows version and buttons when available
- Update Now calls startDownload
- Release Notes calls openReleaseNotes
- Dismiss button works
- Shows progress bar during download
- Shows 0% at start of download
- Shows restart button when ready
- Restart button calls restartApp
All 457 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* design: auto-build-release wireframes
Copy ui-design.pen as base. Frames to be added for:
1. Update notification banner (visible state) — horizontal bar at
top of app shell with version text, Release Notes link, Update
Now button, and dismiss X
2. Update download progress state — spinner icon, progress bar with
percentage, downloading text
3. "Restart to apply" state — green accent, version text, Restart
Now button
Note: Pencil editor was not available during this session. The base
design file is committed; frames will be added when the editor is
accessible. The implemented component (UpdateBanner.tsx) serves as
the source of truth for the design.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update ARCHITECTURE.md with release/update system
Add comprehensive documentation for:
- Release pipeline (4-phase workflow: version → build → release → pages)
- Versioning scheme (0.YYYYMMDD.RUN_NUMBER)
- Universal binary strategy (lipo merge)
- Updater endpoint and latest.json manifest
- In-app update UI state machine
- GitHub Pages release history site
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: rustfmt formatting
* fix: rustfmt build.rs
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:50:36 +01:00
|
|
|
fs::write(path, json).map_err(|e| format!("Failed to write settings: {}", e))
|
2026-02-22 13:33:32 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 18:04:01 +01:00
|
|
|
pub fn get_settings() -> Result<Settings, String> {
|
|
|
|
|
get_settings_at(&settings_path()?)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn save_settings(settings: Settings) -> Result<(), String> {
|
2026-04-12 01:35:34 +02:00
|
|
|
save_settings_at(&preferred_app_config_path("settings.json")?, settings)
|
2026-02-22 18:04:01 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-03 00:29:16 +01:00
|
|
|
fn last_vault_file() -> Result<PathBuf, String> {
|
2026-04-12 01:35:34 +02:00
|
|
|
resolve_existing_or_preferred_app_config_path("last-vault.txt")
|
2026-03-03 00:29:16 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn get_last_vault_at(path: &PathBuf) -> Option<String> {
|
|
|
|
|
fs::read_to_string(path)
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|s| s.trim().to_string())
|
|
|
|
|
.filter(|s| !s.is_empty())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set_last_vault_at(path: &PathBuf, vault_path: &str) -> Result<(), String> {
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
fs::create_dir_all(parent)
|
|
|
|
|
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
|
|
|
|
}
|
|
|
|
|
fs::write(path, vault_path.trim())
|
|
|
|
|
.map_err(|e| format!("Failed to write last vault path: {}", e))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn get_last_vault() -> Option<String> {
|
|
|
|
|
last_vault_file().ok().and_then(|p| get_last_vault_at(&p))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn set_last_vault(vault_path: &str) -> Result<(), String> {
|
2026-04-12 01:35:34 +02:00
|
|
|
set_last_vault_at(&preferred_app_config_path("last-vault.txt")?, vault_path)
|
2026-03-03 00:29:16 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 13:33:32 +01:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
2026-02-22 18:04:01 +01:00
|
|
|
|
2026-04-12 17:08:07 +02:00
|
|
|
fn assert_empty_settings(settings: &Settings) {
|
2026-04-24 22:26:07 +02:00
|
|
|
assert_eq!(settings, &Settings::default());
|
2026-04-12 17:08:07 +02:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 18:04:01 +01:00
|
|
|
/// Helper: save settings to a temp file and reload them.
|
|
|
|
|
fn save_and_reload(settings: Settings) -> Settings {
|
|
|
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let path = dir.path().join("settings.json");
|
|
|
|
|
save_settings_at(&path, settings).unwrap();
|
|
|
|
|
get_settings_at(&path).unwrap()
|
2026-02-22 13:33:32 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 17:08:07 +02:00
|
|
|
fn create_last_vault_path(path_parts: &[&str]) -> (tempfile::TempDir, PathBuf) {
|
|
|
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let path = path_parts
|
|
|
|
|
.iter()
|
|
|
|
|
.fold(dir.path().to_path_buf(), |acc, part| acc.join(part));
|
|
|
|
|
(dir, path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_and_assert_last_vault(path: &PathBuf, value: &str) {
|
|
|
|
|
set_last_vault_at(path, value).unwrap();
|
|
|
|
|
assert_eq!(get_last_vault_at(path).as_deref(), Some(value));
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 13:33:32 +01:00
|
|
|
#[test]
|
2026-02-22 18:04:01 +01:00
|
|
|
fn test_default_settings_all_none() {
|
2026-04-12 17:08:07 +02:00
|
|
|
assert_empty_settings(&Settings::default());
|
2026-02-22 13:33:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-02-22 18:04:01 +01:00
|
|
|
fn test_settings_json_roundtrip() {
|
2026-02-22 13:33:32 +01:00
|
|
|
let settings = Settings {
|
2026-04-12 17:08:07 +02:00
|
|
|
auto_pull_interval_minutes: Some(10),
|
2026-05-16 12:13:20 +02:00
|
|
|
git_enabled: Some(false),
|
2026-04-16 23:38:30 +02:00
|
|
|
autogit_enabled: Some(true),
|
|
|
|
|
autogit_idle_threshold_seconds: Some(90),
|
|
|
|
|
autogit_inactive_threshold_seconds: Some(30),
|
2026-04-24 15:04:08 +09:00
|
|
|
auto_advance_inbox_after_organize: Some(true),
|
2026-03-25 16:05:13 +01:00
|
|
|
telemetry_consent: Some(true),
|
|
|
|
|
crash_reporting_enabled: Some(true),
|
|
|
|
|
analytics_enabled: Some(false),
|
|
|
|
|
anonymous_id: Some("abc-123-uuid".to_string()),
|
2026-04-12 22:14:53 +02:00
|
|
|
release_channel: Some("alpha".to_string()),
|
2026-04-24 22:26:07 +02:00
|
|
|
theme_mode: Some("dark".to_string()),
|
2026-04-26 08:18:47 +02:00
|
|
|
ui_language: Some("zh-Hans".to_string()),
|
2026-05-11 13:46:18 +02:00
|
|
|
date_display_format: Some("iso".to_string()),
|
2026-04-29 19:26:24 +02:00
|
|
|
note_width_mode: Some("wide".to_string()),
|
2026-05-04 21:54:06 +02:00
|
|
|
sidebar_type_pluralization_enabled: Some(false),
|
2026-04-16 12:18:11 +02:00
|
|
|
initial_h1_auto_rename_enabled: Some(false),
|
2026-05-12 22:30:24 +02:00
|
|
|
ai_features_enabled: Some(false),
|
2026-04-13 19:37:59 +02:00
|
|
|
default_ai_agent: Some("codex".to_string()),
|
2026-05-03 16:15:03 +02:00
|
|
|
default_ai_target: Some("agent:codex".to_string()),
|
|
|
|
|
ai_model_providers: None,
|
2026-05-26 19:15:05 +02:00
|
|
|
ai_workspace_conversations: None,
|
2026-04-28 08:55:40 +02:00
|
|
|
hide_gitignored_files: Some(false),
|
2026-05-11 16:37:06 +02:00
|
|
|
multi_workspace_enabled: Some(true),
|
2026-04-30 02:02:59 +02:00
|
|
|
all_notes_show_pdfs: Some(true),
|
|
|
|
|
all_notes_show_images: Some(true),
|
|
|
|
|
all_notes_show_unsupported: Some(false),
|
2026-02-22 13:33:32 +01:00
|
|
|
};
|
|
|
|
|
let json = serde_json::to_string(&settings).unwrap();
|
|
|
|
|
let parsed: Settings = serde_json::from_str(&json).unwrap();
|
2026-04-24 22:26:07 +02:00
|
|
|
assert_eq!(parsed, settings);
|
2026-02-22 13:33:32 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-02-22 18:04:01 +01:00
|
|
|
fn test_get_settings_returns_default_for_missing_file() {
|
|
|
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let path = dir.path().join("nonexistent.json");
|
|
|
|
|
let result = get_settings_at(&path).unwrap();
|
2026-04-12 17:08:07 +02:00
|
|
|
assert!(result.auto_pull_interval_minutes.is_none());
|
2026-02-22 18:04:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_save_and_load_preserves_values() {
|
|
|
|
|
let loaded = save_and_reload(Settings {
|
feat: auto-pull vault changes from Git (background sync + conflict handling) (#79)
* feat: add auto-pull vault sync with conflict handling
- Add git_pull, has_remote, get_conflict_files to Rust backend
- Add GitPullResult type and auto_pull_interval_minutes to Settings
- Create useAutoSync hook (pull on launch, focus, periodic interval)
- Update StatusBar with real sync status indicator
- Add pull interval setting to SettingsPanel
- Add mock handler for git_pull
- Update existing tests for new Settings field
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: add tests for git pull, settings, and useAutoSync hook
- Add Rust tests: has_remote, git_pull (no_remote, up_to_date, updated),
get_conflict_files, parse_updated_files, GitPullResult serialization
- Add frontend tests: useAutoSync (mount pull, focus pull, conflict,
error, manual trigger, concurrent prevention, no_remote)
- Fix existing settings tests for new auto_pull_interval_minutes field
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* design: add auto-pull-vault wireframes
Frames showing: sync idle, syncing, conflict indicator,
settings sync section, and conflict toast notification.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add auto_pull_interval_minutes to all Settings literals
Fix build error and test fixtures missing the new field.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: cargo fmt
* ci: re-trigger CI after flaky test
* ci: retrigger after disk space cleanup
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 01:55:16 +01:00
|
|
|
auto_pull_interval_minutes: Some(10),
|
2026-05-16 12:13:20 +02:00
|
|
|
git_enabled: Some(false),
|
2026-04-16 23:38:30 +02:00
|
|
|
autogit_enabled: Some(true),
|
|
|
|
|
autogit_idle_threshold_seconds: Some(90),
|
|
|
|
|
autogit_inactive_threshold_seconds: Some(30),
|
2026-04-24 15:04:08 +09:00
|
|
|
auto_advance_inbox_after_organize: Some(true),
|
2026-04-12 17:08:07 +02:00
|
|
|
release_channel: Some("alpha".to_string()),
|
2026-04-24 22:26:07 +02:00
|
|
|
theme_mode: Some("dark".to_string()),
|
2026-04-26 08:18:47 +02:00
|
|
|
ui_language: Some("zh-Hans".to_string()),
|
2026-05-11 13:46:18 +02:00
|
|
|
date_display_format: Some("european".to_string()),
|
2026-04-29 19:26:24 +02:00
|
|
|
note_width_mode: Some("wide".to_string()),
|
2026-05-04 21:54:06 +02:00
|
|
|
sidebar_type_pluralization_enabled: Some(false),
|
2026-04-16 12:18:11 +02:00
|
|
|
initial_h1_auto_rename_enabled: Some(false),
|
2026-05-12 22:30:24 +02:00
|
|
|
ai_features_enabled: Some(false),
|
2026-04-13 19:37:59 +02:00
|
|
|
default_ai_agent: Some("codex".to_string()),
|
2026-04-28 08:55:40 +02:00
|
|
|
hide_gitignored_files: Some(false),
|
2026-05-11 16:37:06 +02:00
|
|
|
multi_workspace_enabled: Some(true),
|
2026-04-30 02:02:59 +02:00
|
|
|
all_notes_show_pdfs: Some(true),
|
|
|
|
|
all_notes_show_images: Some(false),
|
|
|
|
|
all_notes_show_unsupported: Some(true),
|
2026-03-25 16:05:13 +01:00
|
|
|
..Default::default()
|
2026-02-22 18:04:01 +01:00
|
|
|
});
|
feat: auto-pull vault changes from Git (background sync + conflict handling) (#79)
* feat: add auto-pull vault sync with conflict handling
- Add git_pull, has_remote, get_conflict_files to Rust backend
- Add GitPullResult type and auto_pull_interval_minutes to Settings
- Create useAutoSync hook (pull on launch, focus, periodic interval)
- Update StatusBar with real sync status indicator
- Add pull interval setting to SettingsPanel
- Add mock handler for git_pull
- Update existing tests for new Settings field
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: add tests for git pull, settings, and useAutoSync hook
- Add Rust tests: has_remote, git_pull (no_remote, up_to_date, updated),
get_conflict_files, parse_updated_files, GitPullResult serialization
- Add frontend tests: useAutoSync (mount pull, focus pull, conflict,
error, manual trigger, concurrent prevention, no_remote)
- Fix existing settings tests for new auto_pull_interval_minutes field
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* design: add auto-pull-vault wireframes
Frames showing: sync idle, syncing, conflict indicator,
settings sync section, and conflict toast notification.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add auto_pull_interval_minutes to all Settings literals
Fix build error and test fixtures missing the new field.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: cargo fmt
* ci: re-trigger CI after flaky test
* ci: retrigger after disk space cleanup
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 01:55:16 +01:00
|
|
|
assert_eq!(loaded.auto_pull_interval_minutes, Some(10));
|
2026-05-16 12:13:20 +02:00
|
|
|
assert_eq!(loaded.git_enabled, Some(false));
|
2026-04-16 23:38:30 +02:00
|
|
|
assert_eq!(loaded.autogit_enabled, Some(true));
|
|
|
|
|
assert_eq!(loaded.autogit_idle_threshold_seconds, Some(90));
|
|
|
|
|
assert_eq!(loaded.autogit_inactive_threshold_seconds, Some(30));
|
2026-04-24 15:04:08 +09:00
|
|
|
assert_eq!(loaded.auto_advance_inbox_after_organize, Some(true));
|
2026-04-12 17:08:07 +02:00
|
|
|
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
|
2026-04-24 22:26:07 +02:00
|
|
|
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
|
2026-04-30 13:20:03 +02:00
|
|
|
assert_eq!(loaded.ui_language.as_deref(), Some("zh-CN"));
|
2026-05-11 13:46:18 +02:00
|
|
|
assert_eq!(loaded.date_display_format.as_deref(), Some("european"));
|
2026-04-29 19:26:24 +02:00
|
|
|
assert_eq!(loaded.note_width_mode.as_deref(), Some("wide"));
|
2026-05-04 21:54:06 +02:00
|
|
|
assert_eq!(loaded.sidebar_type_pluralization_enabled, Some(false));
|
2026-04-16 12:18:11 +02:00
|
|
|
assert_eq!(loaded.initial_h1_auto_rename_enabled, Some(false));
|
2026-05-12 22:30:24 +02:00
|
|
|
assert_eq!(loaded.ai_features_enabled, Some(false));
|
2026-04-13 19:37:59 +02:00
|
|
|
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
|
2026-04-28 08:55:40 +02:00
|
|
|
assert_eq!(loaded.hide_gitignored_files, Some(false));
|
2026-05-11 16:37:06 +02:00
|
|
|
assert_eq!(loaded.multi_workspace_enabled, Some(true));
|
2026-04-30 02:02:59 +02:00
|
|
|
assert_eq!(loaded.all_notes_show_pdfs, Some(true));
|
|
|
|
|
assert_eq!(loaded.all_notes_show_images, Some(false));
|
|
|
|
|
assert_eq!(loaded.all_notes_show_unsupported, Some(true));
|
2026-04-28 08:55:40 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_gitignored_files_are_hidden_by_default() {
|
|
|
|
|
assert!(should_hide_gitignored_files(&Settings::default()));
|
|
|
|
|
assert!(should_hide_gitignored_files(&Settings {
|
|
|
|
|
hide_gitignored_files: Some(true),
|
|
|
|
|
..Default::default()
|
|
|
|
|
}));
|
|
|
|
|
assert!(!should_hide_gitignored_files(&Settings {
|
|
|
|
|
hide_gitignored_files: Some(false),
|
|
|
|
|
..Default::default()
|
|
|
|
|
}));
|
2026-02-22 18:04:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_save_trims_whitespace() {
|
|
|
|
|
let loaded = save_and_reload(Settings {
|
2026-04-12 17:08:07 +02:00
|
|
|
anonymous_id: Some(" test-uuid ".to_string()),
|
2026-04-12 22:14:53 +02:00
|
|
|
release_channel: Some(" alpha ".to_string()),
|
2026-04-24 22:26:07 +02:00
|
|
|
theme_mode: Some(" dark ".to_string()),
|
2026-04-26 08:18:47 +02:00
|
|
|
ui_language: Some(" zh-cn ".to_string()),
|
2026-05-11 13:46:18 +02:00
|
|
|
date_display_format: Some(" ISO ".to_string()),
|
2026-04-29 19:26:24 +02:00
|
|
|
note_width_mode: Some(" WIDE ".to_string()),
|
2026-04-13 19:37:59 +02:00
|
|
|
default_ai_agent: Some(" codex ".to_string()),
|
2026-02-23 19:56:30 +01:00
|
|
|
..Default::default()
|
2026-02-22 18:04:01 +01:00
|
|
|
});
|
2026-04-12 17:08:07 +02:00
|
|
|
assert_eq!(loaded.anonymous_id.as_deref(), Some("test-uuid"));
|
2026-04-12 22:14:53 +02:00
|
|
|
assert_eq!(loaded.release_channel.as_deref(), Some("alpha"));
|
2026-04-24 22:26:07 +02:00
|
|
|
assert_eq!(loaded.theme_mode.as_deref(), Some("dark"));
|
2026-04-30 13:20:03 +02:00
|
|
|
assert_eq!(loaded.ui_language.as_deref(), Some("zh-CN"));
|
2026-05-11 13:46:18 +02:00
|
|
|
assert_eq!(loaded.date_display_format.as_deref(), Some("iso"));
|
2026-04-29 19:26:24 +02:00
|
|
|
assert_eq!(loaded.note_width_mode.as_deref(), Some("wide"));
|
2026-04-13 19:37:59 +02:00
|
|
|
assert_eq!(loaded.default_ai_agent.as_deref(), Some("codex"));
|
2026-02-22 18:04:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_save_filters_empty_and_whitespace_only() {
|
|
|
|
|
let loaded = save_and_reload(Settings {
|
2026-04-12 17:08:07 +02:00
|
|
|
release_channel: Some("".to_string()),
|
2026-02-23 19:56:30 +01:00
|
|
|
..Default::default()
|
2026-02-22 18:04:01 +01:00
|
|
|
});
|
2026-04-12 17:08:07 +02:00
|
|
|
assert!(loaded.release_channel.is_none());
|
2026-02-22 18:04:01 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-16 23:38:30 +02:00
|
|
|
#[test]
|
|
|
|
|
fn test_non_positive_autogit_thresholds_are_filtered() {
|
|
|
|
|
let loaded = save_and_reload(Settings {
|
|
|
|
|
autogit_idle_threshold_seconds: Some(0),
|
|
|
|
|
autogit_inactive_threshold_seconds: Some(0),
|
|
|
|
|
..Default::default()
|
|
|
|
|
});
|
|
|
|
|
assert!(loaded.autogit_idle_threshold_seconds.is_none());
|
|
|
|
|
assert!(loaded.autogit_inactive_threshold_seconds.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 22:14:53 +02:00
|
|
|
#[test]
|
|
|
|
|
fn test_non_alpha_release_channels_normalize_to_stable() {
|
|
|
|
|
let loaded = save_and_reload(Settings {
|
|
|
|
|
release_channel: Some("beta".to_string()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
});
|
|
|
|
|
assert!(loaded.release_channel.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 19:37:59 +02:00
|
|
|
#[test]
|
|
|
|
|
fn test_invalid_default_ai_agent_is_filtered() {
|
|
|
|
|
let loaded = save_and_reload(Settings {
|
|
|
|
|
default_ai_agent: Some("cursor".to_string()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
});
|
|
|
|
|
assert!(loaded.default_ai_agent.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 01:26:21 +02:00
|
|
|
#[test]
|
|
|
|
|
fn test_opencode_default_ai_agent_is_preserved() {
|
|
|
|
|
let loaded = save_and_reload(Settings {
|
|
|
|
|
default_ai_agent: Some("opencode".to_string()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
});
|
|
|
|
|
assert_eq!(loaded.default_ai_agent.as_deref(), Some("opencode"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-28 03:41:04 +02:00
|
|
|
#[test]
|
|
|
|
|
fn test_pi_default_ai_agent_is_preserved() {
|
|
|
|
|
let loaded = save_and_reload(Settings {
|
|
|
|
|
default_ai_agent: Some("pi".to_string()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
});
|
|
|
|
|
assert_eq!(loaded.default_ai_agent.as_deref(), Some("pi"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 11:16:52 +02:00
|
|
|
#[test]
|
|
|
|
|
fn test_gemini_default_ai_agent_is_preserved() {
|
|
|
|
|
let loaded = save_and_reload(Settings {
|
|
|
|
|
default_ai_agent: Some("gemini".to_string()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
});
|
|
|
|
|
assert_eq!(loaded.default_ai_agent.as_deref(), Some("gemini"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 22:26:07 +02:00
|
|
|
#[test]
|
2026-05-05 15:00:24 +02:00
|
|
|
fn test_system_theme_mode_is_preserved() {
|
2026-04-24 22:26:07 +02:00
|
|
|
let loaded = save_and_reload(Settings {
|
|
|
|
|
theme_mode: Some("system".to_string()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
});
|
2026-05-05 15:00:24 +02:00
|
|
|
assert_eq!(loaded.theme_mode.as_deref(), Some("system"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_invalid_theme_mode_is_filtered() {
|
|
|
|
|
let loaded = save_and_reload(Settings {
|
|
|
|
|
theme_mode: Some("sepia".to_string()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
});
|
2026-04-24 22:26:07 +02:00
|
|
|
assert!(loaded.theme_mode.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 19:26:24 +02:00
|
|
|
#[test]
|
|
|
|
|
fn test_invalid_note_width_mode_is_filtered() {
|
|
|
|
|
let loaded = save_and_reload(Settings {
|
|
|
|
|
note_width_mode: Some("expanded".to_string()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
});
|
|
|
|
|
assert!(loaded.note_width_mode.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 13:46:18 +02:00
|
|
|
#[test]
|
|
|
|
|
fn test_invalid_date_display_format_is_filtered() {
|
|
|
|
|
let loaded = save_and_reload(Settings {
|
|
|
|
|
date_display_format: Some("relative".to_string()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
});
|
|
|
|
|
assert!(loaded.date_display_format.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 08:18:47 +02:00
|
|
|
#[test]
|
|
|
|
|
fn test_invalid_ui_language_is_filtered() {
|
|
|
|
|
let loaded = save_and_reload(Settings {
|
2026-04-30 12:45:10 +02:00
|
|
|
ui_language: Some("xx-ZZ".to_string()),
|
2026-04-26 08:18:47 +02:00
|
|
|
..Default::default()
|
|
|
|
|
});
|
|
|
|
|
assert!(loaded.ui_language.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-30 12:45:10 +02:00
|
|
|
#[test]
|
|
|
|
|
fn test_supported_ui_languages_are_saved_and_reloaded() {
|
|
|
|
|
let expected_languages = [
|
|
|
|
|
("it-IT", "it-IT"),
|
|
|
|
|
("fr-FR", "fr-FR"),
|
|
|
|
|
("de-DE", "de-DE"),
|
|
|
|
|
("ru-RU", "ru-RU"),
|
|
|
|
|
("es-ES", "es-ES"),
|
|
|
|
|
("pt-BR", "pt-BR"),
|
|
|
|
|
("pt-PT", "pt-PT"),
|
|
|
|
|
("es-419", "es-419"),
|
2026-04-30 13:20:03 +02:00
|
|
|
("zh-CN", "zh-CN"),
|
2026-04-30 12:45:10 +02:00
|
|
|
("zh-TW", "zh-TW"),
|
|
|
|
|
("ja-JP", "ja-JP"),
|
|
|
|
|
("ko-KR", "ko-KR"),
|
|
|
|
|
("vi", "vi"),
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (input, expected) in expected_languages {
|
|
|
|
|
let loaded = save_and_reload(Settings {
|
|
|
|
|
ui_language: Some(input.to_string()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
});
|
|
|
|
|
assert_eq!(loaded.ui_language.as_deref(), Some(expected));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 08:18:47 +02:00
|
|
|
#[test]
|
|
|
|
|
fn test_ui_language_aliases_are_canonicalized() {
|
|
|
|
|
assert_eq!(normalize_ui_language(Some("en-US")).as_deref(), Some("en"));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
normalize_ui_language(Some("zh_CN")).as_deref(),
|
2026-04-30 13:20:03 +02:00
|
|
|
Some("zh-CN")
|
2026-04-26 08:18:47 +02:00
|
|
|
);
|
2026-04-30 12:45:10 +02:00
|
|
|
assert_eq!(
|
|
|
|
|
normalize_ui_language(Some("zh-Hant")).as_deref(),
|
|
|
|
|
Some("zh-TW")
|
|
|
|
|
);
|
2026-04-26 08:18:47 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 22:14:53 +02:00
|
|
|
#[test]
|
|
|
|
|
fn test_get_settings_normalizes_legacy_beta_channel() {
|
|
|
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let path = dir.path().join("settings.json");
|
|
|
|
|
fs::write(&path, r#"{"release_channel":"beta"}"#).unwrap();
|
|
|
|
|
|
|
|
|
|
let loaded = get_settings_at(&path).unwrap();
|
|
|
|
|
assert!(loaded.release_channel.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 18:04:01 +01:00
|
|
|
#[test]
|
|
|
|
|
fn test_save_creates_parent_directories() {
|
|
|
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let path = dir.path().join("nested").join("dir").join("settings.json");
|
|
|
|
|
|
feat: auto-build, GitHub Release, and in-app updater (#14)
* ci: auto-release workflow on merge to main
Rewrite .github/workflows/release.yml to trigger on every push to main
instead of manual tag pushes. The workflow now:
- Computes version as 0.YYYYMMDD.GITHUB_RUN_NUMBER
- Builds aarch64-apple-darwin and x86_64-apple-darwin in parallel
- Merges them into a universal binary using lipo
- Creates a universal .dmg and signed updater tarball
- Generates latest.json with per-arch and universal platform entries
- Publishes a GitHub Release with auto-generated release notes
- Updates a GitHub Pages release history site (gh-pages branch)
Product decisions:
- Universal binary approach: copy arm64 .app as base, lipo the main
executable, keep everything else from arm64 (shared frameworks are
architecture-independent). This is the standard Tauri pattern.
- Per-arch updater tarballs are also uploaded so the Tauri updater can
download the correct arch-specific build (smaller download).
- Release notes are auto-generated from git log since last tag.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* ci: github pages with release history
Use peaceiris/actions-gh-pages@v4 to deploy a release history site.
The page fetches releases.json (also deployed) and renders each release
with date, notes, and download links for .dmg files.
This handles the gh-pages branch creation automatically on first run.
The page is available at https://refactoringhq.github.io/laputa-app/
Product decision: used fetch() to load releases.json at runtime instead
of inlining it, which is cleaner and avoids shell escaping issues with
release note content. The releases.json is deployed alongside index.html.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: in-app update notification UI
Replace the old window.confirm updater with a proper React-based
update notification system:
- useUpdater hook now exposes state machine (idle → available →
downloading → ready) and actions (startDownload, openReleaseNotes,
dismiss)
- UpdateBanner component renders at the top of the app shell:
- "Available" state: shows version, Release Notes link, Update Now
button, dismiss X
- "Downloading" state: animated spinner, progress bar with percentage
- "Ready" state: Restart Now button to apply the update
- Silently checks on startup after 3s delay; fails silently on
network errors or 404
- Release Notes link opens the GitHub Pages release history site
Product decisions:
- Banner at top of app (not a modal) — non-intrusive, visible but
not blocking. User can dismiss and continue working.
- Progress bar shows during download so user knows it's working.
- Separate "Restart Now" state after download so user controls when
the app restarts (they may have unsaved work).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: updater component tests
Rewrite useUpdater hook tests and add UpdateBanner component tests:
Hook tests (10 cases):
- Starts in idle state
- Does nothing when not in Tauri
- Checks for updates after 3s delay
- Stays idle when no update available
- Transitions to available when update found
- Handles missing release body gracefully
- Stays idle on network error (fails silently)
- Dismiss returns to idle
- openReleaseNotes opens correct URL
- startDownload transitions through downloading to ready
Component tests (10 cases):
- Renders nothing when idle
- Renders nothing on error
- Shows version and buttons when available
- Update Now calls startDownload
- Release Notes calls openReleaseNotes
- Dismiss button works
- Shows progress bar during download
- Shows 0% at start of download
- Shows restart button when ready
- Restart button calls restartApp
All 457 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* design: auto-build-release wireframes
Copy ui-design.pen as base. Frames to be added for:
1. Update notification banner (visible state) — horizontal bar at
top of app shell with version text, Release Notes link, Update
Now button, and dismiss X
2. Update download progress state — spinner icon, progress bar with
percentage, downloading text
3. "Restart to apply" state — green accent, version text, Restart
Now button
Note: Pencil editor was not available during this session. The base
design file is committed; frames will be added when the editor is
accessible. The implemented component (UpdateBanner.tsx) serves as
the source of truth for the design.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update ARCHITECTURE.md with release/update system
Add comprehensive documentation for:
- Release pipeline (4-phase workflow: version → build → release → pages)
- Versioning scheme (0.YYYYMMDD.RUN_NUMBER)
- Universal binary strategy (lipo merge)
- Updater endpoint and latest.json manifest
- In-app update UI state machine
- GitHub Pages release history site
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: rustfmt formatting
* fix: rustfmt build.rs
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:50:36 +01:00
|
|
|
save_settings_at(
|
|
|
|
|
&path,
|
|
|
|
|
Settings {
|
2026-04-12 17:08:07 +02:00
|
|
|
anonymous_id: Some("test-uuid".to_string()),
|
feat: auto-build, GitHub Release, and in-app updater (#14)
* ci: auto-release workflow on merge to main
Rewrite .github/workflows/release.yml to trigger on every push to main
instead of manual tag pushes. The workflow now:
- Computes version as 0.YYYYMMDD.GITHUB_RUN_NUMBER
- Builds aarch64-apple-darwin and x86_64-apple-darwin in parallel
- Merges them into a universal binary using lipo
- Creates a universal .dmg and signed updater tarball
- Generates latest.json with per-arch and universal platform entries
- Publishes a GitHub Release with auto-generated release notes
- Updates a GitHub Pages release history site (gh-pages branch)
Product decisions:
- Universal binary approach: copy arm64 .app as base, lipo the main
executable, keep everything else from arm64 (shared frameworks are
architecture-independent). This is the standard Tauri pattern.
- Per-arch updater tarballs are also uploaded so the Tauri updater can
download the correct arch-specific build (smaller download).
- Release notes are auto-generated from git log since last tag.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* ci: github pages with release history
Use peaceiris/actions-gh-pages@v4 to deploy a release history site.
The page fetches releases.json (also deployed) and renders each release
with date, notes, and download links for .dmg files.
This handles the gh-pages branch creation automatically on first run.
The page is available at https://refactoringhq.github.io/laputa-app/
Product decision: used fetch() to load releases.json at runtime instead
of inlining it, which is cleaner and avoids shell escaping issues with
release note content. The releases.json is deployed alongside index.html.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: in-app update notification UI
Replace the old window.confirm updater with a proper React-based
update notification system:
- useUpdater hook now exposes state machine (idle → available →
downloading → ready) and actions (startDownload, openReleaseNotes,
dismiss)
- UpdateBanner component renders at the top of the app shell:
- "Available" state: shows version, Release Notes link, Update Now
button, dismiss X
- "Downloading" state: animated spinner, progress bar with percentage
- "Ready" state: Restart Now button to apply the update
- Silently checks on startup after 3s delay; fails silently on
network errors or 404
- Release Notes link opens the GitHub Pages release history site
Product decisions:
- Banner at top of app (not a modal) — non-intrusive, visible but
not blocking. User can dismiss and continue working.
- Progress bar shows during download so user knows it's working.
- Separate "Restart Now" state after download so user controls when
the app restarts (they may have unsaved work).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: updater component tests
Rewrite useUpdater hook tests and add UpdateBanner component tests:
Hook tests (10 cases):
- Starts in idle state
- Does nothing when not in Tauri
- Checks for updates after 3s delay
- Stays idle when no update available
- Transitions to available when update found
- Handles missing release body gracefully
- Stays idle on network error (fails silently)
- Dismiss returns to idle
- openReleaseNotes opens correct URL
- startDownload transitions through downloading to ready
Component tests (10 cases):
- Renders nothing when idle
- Renders nothing on error
- Shows version and buttons when available
- Update Now calls startDownload
- Release Notes calls openReleaseNotes
- Dismiss button works
- Shows progress bar during download
- Shows 0% at start of download
- Shows restart button when ready
- Restart button calls restartApp
All 457 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* design: auto-build-release wireframes
Copy ui-design.pen as base. Frames to be added for:
1. Update notification banner (visible state) — horizontal bar at
top of app shell with version text, Release Notes link, Update
Now button, and dismiss X
2. Update download progress state — spinner icon, progress bar with
percentage, downloading text
3. "Restart to apply" state — green accent, version text, Restart
Now button
Note: Pencil editor was not available during this session. The base
design file is committed; frames will be added when the editor is
accessible. The implemented component (UpdateBanner.tsx) serves as
the source of truth for the design.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update ARCHITECTURE.md with release/update system
Add comprehensive documentation for:
- Release pipeline (4-phase workflow: version → build → release → pages)
- Versioning scheme (0.YYYYMMDD.RUN_NUMBER)
- Universal binary strategy (lipo merge)
- Updater endpoint and latest.json manifest
- In-app update UI state machine
- GitHub Pages release history site
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: rustfmt formatting
* fix: rustfmt build.rs
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:50:36 +01:00
|
|
|
..Default::default()
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
2026-02-22 18:04:01 +01:00
|
|
|
assert!(path.exists());
|
feat: auto-build, GitHub Release, and in-app updater (#14)
* ci: auto-release workflow on merge to main
Rewrite .github/workflows/release.yml to trigger on every push to main
instead of manual tag pushes. The workflow now:
- Computes version as 0.YYYYMMDD.GITHUB_RUN_NUMBER
- Builds aarch64-apple-darwin and x86_64-apple-darwin in parallel
- Merges them into a universal binary using lipo
- Creates a universal .dmg and signed updater tarball
- Generates latest.json with per-arch and universal platform entries
- Publishes a GitHub Release with auto-generated release notes
- Updates a GitHub Pages release history site (gh-pages branch)
Product decisions:
- Universal binary approach: copy arm64 .app as base, lipo the main
executable, keep everything else from arm64 (shared frameworks are
architecture-independent). This is the standard Tauri pattern.
- Per-arch updater tarballs are also uploaded so the Tauri updater can
download the correct arch-specific build (smaller download).
- Release notes are auto-generated from git log since last tag.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* ci: github pages with release history
Use peaceiris/actions-gh-pages@v4 to deploy a release history site.
The page fetches releases.json (also deployed) and renders each release
with date, notes, and download links for .dmg files.
This handles the gh-pages branch creation automatically on first run.
The page is available at https://refactoringhq.github.io/laputa-app/
Product decision: used fetch() to load releases.json at runtime instead
of inlining it, which is cleaner and avoids shell escaping issues with
release note content. The releases.json is deployed alongside index.html.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: in-app update notification UI
Replace the old window.confirm updater with a proper React-based
update notification system:
- useUpdater hook now exposes state machine (idle → available →
downloading → ready) and actions (startDownload, openReleaseNotes,
dismiss)
- UpdateBanner component renders at the top of the app shell:
- "Available" state: shows version, Release Notes link, Update Now
button, dismiss X
- "Downloading" state: animated spinner, progress bar with percentage
- "Ready" state: Restart Now button to apply the update
- Silently checks on startup after 3s delay; fails silently on
network errors or 404
- Release Notes link opens the GitHub Pages release history site
Product decisions:
- Banner at top of app (not a modal) — non-intrusive, visible but
not blocking. User can dismiss and continue working.
- Progress bar shows during download so user knows it's working.
- Separate "Restart Now" state after download so user controls when
the app restarts (they may have unsaved work).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: updater component tests
Rewrite useUpdater hook tests and add UpdateBanner component tests:
Hook tests (10 cases):
- Starts in idle state
- Does nothing when not in Tauri
- Checks for updates after 3s delay
- Stays idle when no update available
- Transitions to available when update found
- Handles missing release body gracefully
- Stays idle on network error (fails silently)
- Dismiss returns to idle
- openReleaseNotes opens correct URL
- startDownload transitions through downloading to ready
Component tests (10 cases):
- Renders nothing when idle
- Renders nothing on error
- Shows version and buttons when available
- Update Now calls startDownload
- Release Notes calls openReleaseNotes
- Dismiss button works
- Shows progress bar during download
- Shows 0% at start of download
- Shows restart button when ready
- Restart button calls restartApp
All 457 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* design: auto-build-release wireframes
Copy ui-design.pen as base. Frames to be added for:
1. Update notification banner (visible state) — horizontal bar at
top of app shell with version text, Release Notes link, Update
Now button, and dismiss X
2. Update download progress state — spinner icon, progress bar with
percentage, downloading text
3. "Restart to apply" state — green accent, version text, Restart
Now button
Note: Pencil editor was not available during this session. The base
design file is committed; frames will be added when the editor is
accessible. The implemented component (UpdateBanner.tsx) serves as
the source of truth for the design.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update ARCHITECTURE.md with release/update system
Add comprehensive documentation for:
- Release pipeline (4-phase workflow: version → build → release → pages)
- Versioning scheme (0.YYYYMMDD.RUN_NUMBER)
- Universal binary strategy (lipo merge)
- Updater endpoint and latest.json manifest
- In-app update UI state machine
- GitHub Pages release history site
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: rustfmt formatting
* fix: rustfmt build.rs
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:50:36 +01:00
|
|
|
assert_eq!(
|
2026-04-12 17:08:07 +02:00
|
|
|
get_settings_at(&path).unwrap().anonymous_id.as_deref(),
|
|
|
|
|
Some("test-uuid")
|
feat: auto-build, GitHub Release, and in-app updater (#14)
* ci: auto-release workflow on merge to main
Rewrite .github/workflows/release.yml to trigger on every push to main
instead of manual tag pushes. The workflow now:
- Computes version as 0.YYYYMMDD.GITHUB_RUN_NUMBER
- Builds aarch64-apple-darwin and x86_64-apple-darwin in parallel
- Merges them into a universal binary using lipo
- Creates a universal .dmg and signed updater tarball
- Generates latest.json with per-arch and universal platform entries
- Publishes a GitHub Release with auto-generated release notes
- Updates a GitHub Pages release history site (gh-pages branch)
Product decisions:
- Universal binary approach: copy arm64 .app as base, lipo the main
executable, keep everything else from arm64 (shared frameworks are
architecture-independent). This is the standard Tauri pattern.
- Per-arch updater tarballs are also uploaded so the Tauri updater can
download the correct arch-specific build (smaller download).
- Release notes are auto-generated from git log since last tag.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* ci: github pages with release history
Use peaceiris/actions-gh-pages@v4 to deploy a release history site.
The page fetches releases.json (also deployed) and renders each release
with date, notes, and download links for .dmg files.
This handles the gh-pages branch creation automatically on first run.
The page is available at https://refactoringhq.github.io/laputa-app/
Product decision: used fetch() to load releases.json at runtime instead
of inlining it, which is cleaner and avoids shell escaping issues with
release note content. The releases.json is deployed alongside index.html.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: in-app update notification UI
Replace the old window.confirm updater with a proper React-based
update notification system:
- useUpdater hook now exposes state machine (idle → available →
downloading → ready) and actions (startDownload, openReleaseNotes,
dismiss)
- UpdateBanner component renders at the top of the app shell:
- "Available" state: shows version, Release Notes link, Update Now
button, dismiss X
- "Downloading" state: animated spinner, progress bar with percentage
- "Ready" state: Restart Now button to apply the update
- Silently checks on startup after 3s delay; fails silently on
network errors or 404
- Release Notes link opens the GitHub Pages release history site
Product decisions:
- Banner at top of app (not a modal) — non-intrusive, visible but
not blocking. User can dismiss and continue working.
- Progress bar shows during download so user knows it's working.
- Separate "Restart Now" state after download so user controls when
the app restarts (they may have unsaved work).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* test: updater component tests
Rewrite useUpdater hook tests and add UpdateBanner component tests:
Hook tests (10 cases):
- Starts in idle state
- Does nothing when not in Tauri
- Checks for updates after 3s delay
- Stays idle when no update available
- Transitions to available when update found
- Handles missing release body gracefully
- Stays idle on network error (fails silently)
- Dismiss returns to idle
- openReleaseNotes opens correct URL
- startDownload transitions through downloading to ready
Component tests (10 cases):
- Renders nothing when idle
- Renders nothing on error
- Shows version and buttons when available
- Update Now calls startDownload
- Release Notes calls openReleaseNotes
- Dismiss button works
- Shows progress bar during download
- Shows 0% at start of download
- Shows restart button when ready
- Restart button calls restartApp
All 457 tests pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* design: auto-build-release wireframes
Copy ui-design.pen as base. Frames to be added for:
1. Update notification banner (visible state) — horizontal bar at
top of app shell with version text, Release Notes link, Update
Now button, and dismiss X
2. Update download progress state — spinner icon, progress bar with
percentage, downloading text
3. "Restart to apply" state — green accent, version text, Restart
Now button
Note: Pencil editor was not available during this session. The base
design file is committed; frames will be added when the editor is
accessible. The implemented component (UpdateBanner.tsx) serves as
the source of truth for the design.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update ARCHITECTURE.md with release/update system
Add comprehensive documentation for:
- Release pipeline (4-phase workflow: version → build → release → pages)
- Versioning scheme (0.YYYYMMDD.RUN_NUMBER)
- Universal binary strategy (lipo merge)
- Updater endpoint and latest.json manifest
- In-app update UI state machine
- GitHub Pages release history site
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: rustfmt formatting
* fix: rustfmt build.rs
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:50:36 +01:00
|
|
|
);
|
2026-02-22 18:04:01 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_get_settings_malformed_json() {
|
|
|
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let path = dir.path().join("bad.json");
|
|
|
|
|
fs::write(&path, "not valid json{{{").unwrap();
|
|
|
|
|
|
|
|
|
|
let err = get_settings_at(&path).unwrap_err();
|
|
|
|
|
assert!(err.contains("Failed to parse settings"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 16:05:13 +01:00
|
|
|
#[test]
|
|
|
|
|
fn test_telemetry_fields_roundtrip() {
|
|
|
|
|
let loaded = save_and_reload(Settings {
|
|
|
|
|
telemetry_consent: Some(true),
|
|
|
|
|
crash_reporting_enabled: Some(true),
|
|
|
|
|
analytics_enabled: Some(false),
|
|
|
|
|
anonymous_id: Some("test-uuid-v4".to_string()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
});
|
2026-04-12 17:08:07 +02:00
|
|
|
assert_eq!(
|
2026-04-24 22:26:07 +02:00
|
|
|
loaded,
|
|
|
|
|
Settings {
|
|
|
|
|
telemetry_consent: Some(true),
|
|
|
|
|
crash_reporting_enabled: Some(true),
|
|
|
|
|
analytics_enabled: Some(false),
|
|
|
|
|
anonymous_id: Some("test-uuid-v4".to_string()),
|
|
|
|
|
..Default::default()
|
|
|
|
|
}
|
2026-04-12 17:08:07 +02:00
|
|
|
);
|
2026-03-25 16:05:13 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_old_settings_json_missing_telemetry_fields() {
|
|
|
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let path = dir.path().join("settings.json");
|
2026-04-12 17:08:07 +02:00
|
|
|
// Simulate an old settings.json that still contains removed GitHub auth fields.
|
2026-05-26 10:11:53 +02:00
|
|
|
let legacy_token = ["gho", "test"].join("_");
|
|
|
|
|
let legacy_settings = serde_json::json!({
|
|
|
|
|
"github_token": legacy_token,
|
|
|
|
|
"github_username": "lucaong",
|
|
|
|
|
});
|
|
|
|
|
fs::write(&path, legacy_settings.to_string()).unwrap();
|
2026-03-25 16:05:13 +01:00
|
|
|
let loaded = get_settings_at(&path).unwrap();
|
2026-04-12 17:08:07 +02:00
|
|
|
assert_empty_settings(&loaded);
|
2026-03-25 16:05:13 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 18:04:01 +01:00
|
|
|
#[test]
|
|
|
|
|
fn test_settings_path_returns_ok() {
|
|
|
|
|
let result = settings_path();
|
|
|
|
|
assert!(result.is_ok());
|
2026-04-12 01:35:34 +02:00
|
|
|
let path = result.unwrap();
|
|
|
|
|
let path = path.to_str().unwrap();
|
|
|
|
|
assert!(path.contains("com.tolaria.app") || path.contains("com.laputa.app"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_preferred_settings_path_uses_tolaria_namespace() {
|
|
|
|
|
let result = preferred_app_config_path("settings.json");
|
|
|
|
|
assert!(result.is_ok());
|
2026-04-12 01:41:26 +02:00
|
|
|
assert!(result
|
|
|
|
|
.unwrap()
|
|
|
|
|
.to_str()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.contains("com.tolaria.app"));
|
2026-02-22 13:33:32 +01:00
|
|
|
}
|
2026-03-03 00:29:16 +01:00
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_get_last_vault_returns_none_for_missing_file() {
|
|
|
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let path = dir.path().join("last-vault.txt");
|
|
|
|
|
assert!(get_last_vault_at(&path).is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_set_and_get_last_vault_roundtrip() {
|
2026-04-12 17:08:07 +02:00
|
|
|
let (_dir, path) = create_last_vault_path(&["last-vault.txt"]);
|
|
|
|
|
write_and_assert_last_vault(&path, "/Users/test/MyVault");
|
2026-03-03 00:29:16 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_set_last_vault_trims_whitespace() {
|
2026-04-12 17:08:07 +02:00
|
|
|
let (_dir, path) = create_last_vault_path(&["last-vault.txt"]);
|
|
|
|
|
write_and_assert_last_vault(&path, "/Users/test/Vault");
|
2026-03-03 00:29:16 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_get_last_vault_returns_none_for_empty_file() {
|
|
|
|
|
let dir = tempfile::TempDir::new().unwrap();
|
|
|
|
|
let path = dir.path().join("last-vault.txt");
|
|
|
|
|
fs::write(&path, " \n ").unwrap();
|
|
|
|
|
assert!(get_last_vault_at(&path).is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_set_last_vault_creates_parent_directories() {
|
2026-04-12 17:08:07 +02:00
|
|
|
let (_dir, path) = create_last_vault_path(&["nested", "dir", "last-vault.txt"]);
|
|
|
|
|
write_and_assert_last_vault(&path, "/Users/test/Vault");
|
2026-03-03 00:29:16 +01:00
|
|
|
assert!(path.exists());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_set_last_vault_overwrites_previous() {
|
2026-04-12 17:08:07 +02:00
|
|
|
let (_dir, path) = create_last_vault_path(&["last-vault.txt"]);
|
|
|
|
|
write_and_assert_last_vault(&path, "/Users/test/OldVault");
|
|
|
|
|
write_and_assert_last_vault(&path, "/Users/test/NewVault");
|
2026-03-03 00:29:16 +01:00
|
|
|
}
|
2026-02-22 13:33:32 +01:00
|
|
|
}
|