From 57289153d1004fa6970aceb642ea967a9e18d4d3 Mon Sep 17 00:00:00 2001 From: Gihan Rathnayake Date: Fri, 26 Jun 2026 15:09:47 +0800 Subject: [PATCH 1/8] fix: force XWayland backend on Linux to fix unresponsive window controls Under native Wayland (e.g. KWin) the window's input region is not committed until a resize, leaving the title bar controls unresponsive until the window is maximized. Default GDK_BACKEND to x11 on Linux, unless the user has set it explicitly. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/main.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f923db94..7d37a810 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,14 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { + // Force the GTK/WebKitGTK backend to use XWayland on Linux. Under native + // Wayland (e.g. KWin) the window's input region is not committed until a + // resize, leaving the title bar controls unresponsive until the window is + // maximized. Only set it when the user hasn't picked a backend themselves. + #[cfg(target_os = "linux")] + if std::env::var_os("GDK_BACKEND").is_none() { + std::env::set_var("GDK_BACKEND", "x11"); + } + scratch_lib::run() } From 534d9dfa27f80cdbf33dc8644fad6f277227ab40 Mon Sep 17 00:00:00 2001 From: Gihan Rathnayake Date: Fri, 26 Jun 2026 17:32:16 +0800 Subject: [PATCH 2/8] feat: add Inter as an editor font option Bundle the Inter variable font (self-hosted via @fontsource-variable/inter, works offline) and expose it as a fourth editor font alongside Sans, Serif, and Mono. - Add "inter" to the FontFamily type, font map, and settings dropdown - Export fontFamilyMap and reuse it for the settings preview (removes a duplicated, now-incomplete font-stack ternary) - Inter-scoped typographic tuning: negative letter-spacing on body and headings, plus font-optical-sizing; other families keep normal tracking - List spacing polish: gap between sibling items, tighter marker-to-text gap, slightly deeper indent Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 10 ++++++++++ package.json | 1 + src/App.css | 16 ++++++++++++---- .../settings/EditorSettingsSection.tsx | 14 +++++++------- src/context/ThemeContext.tsx | 16 +++++++++++++++- src/main.tsx | 1 + src/types/note.ts | 2 +- 7 files changed, 47 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8cb3fc0e..2f7d6ee7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@fontsource-variable/inter": "^5.2.8", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -876,6 +877,15 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@fontsource-variable/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", diff --git a/package.json b/package.json index acfd2633..f7b512cd 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@fontsource-variable/inter": "^5.2.8", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/src/App.css b/src/App.css index 3b3904d9..4dcee316 100644 --- a/src/App.css +++ b/src/App.css @@ -316,6 +316,8 @@ html.dark { font-size: var(--editor-base-font-size); font-weight: 400; line-height: var(--editor-line-height); + letter-spacing: var(--editor-letter-spacing, normal); + font-optical-sizing: auto; color: var(--color-text); } @@ -332,6 +334,7 @@ html.dark { :where([class~="not-prose"], [class~="not-prose"] *) ) { color: var(--color-text); + letter-spacing: var(--editor-heading-letter-spacing, normal); } /* Headings - all use the same font family with computed sizes */ @@ -671,8 +674,8 @@ html.dark { .prose ol { margin-top: 0; margin-bottom: var(--editor-paragraph-spacing); - padding-left: 1.25em; - padding-inline-start: 1.25em; + padding-left: 1.5em; + padding-inline-start: 1.5em; } /* Remove extra margins from nested lists */ @@ -685,8 +688,13 @@ html.dark { .prose li { margin-top: 0; margin-bottom: 0; - padding-left: 0.25em; - padding-inline-start: 0.25em; + padding-left: 0.1em; + padding-inline-start: 0.1em; +} + +/* Breathing room between sibling list items (not before the first) */ +.prose li + li { + margin-top: 0.3em; } /* List markers - use muted stone color */ diff --git a/src/components/settings/EditorSettingsSection.tsx b/src/components/settings/EditorSettingsSection.tsx index 997e9180..32ffab75 100644 --- a/src/components/settings/EditorSettingsSection.tsx +++ b/src/components/settings/EditorSettingsSection.tsx @@ -1,4 +1,8 @@ -import { useTheme, defaultThemeColors } from "../../context/ThemeContext"; +import { + useTheme, + defaultThemeColors, + fontFamilyMap, +} from "../../context/ThemeContext"; import { Button, CodeCopyButton, IconButton, Input, Select } from "../ui"; import { ColorPicker } from "../ui/ColorPicker"; import type { @@ -44,6 +48,7 @@ const editorWidthOptions: { value: EditorWidth; label: string }[] = [ // Font family options const fontFamilyOptions: { value: FontFamily; label: string }[] = [ { value: "system-sans", label: "Sans" }, + { value: "inter", label: "Inter" }, { value: "serif", label: "Serif" }, { value: "monospace", label: "Mono" }, ]; @@ -366,12 +371,7 @@ export function AppearanceSettingsSection() { className="prose prose-lg dark:prose-invert max-w-xl mx-auto" dir={textDirection} style={{ - fontFamily: - editorFontSettings.baseFontFamily === "system-sans" - ? "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif" - : editorFontSettings.baseFontFamily === "serif" - ? "ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif" - : "ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace", + fontFamily: fontFamilyMap[editorFontSettings.baseFontFamily], fontSize: `${editorFontSettings.baseFontSize}px`, }} > diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index f6b3472b..8898a9df 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -21,9 +21,11 @@ import type { type ThemeMode = "light" | "dark" | "system"; // Font family CSS values -const fontFamilyMap: Record = { +export const fontFamilyMap: Record = { "system-sans": '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + inter: + '"Inter Variable", "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', serif: 'ui-serif, Georgia, Cambria, "Times New Roman", Times, serif', monospace: "ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Monaco, 'Courier New', monospace", @@ -147,6 +149,18 @@ function applyFontCSSVariables(fonts: Required) { root.style.setProperty("--editor-bold-weight", String(boldWeight)); root.style.setProperty("--editor-line-height", String(lineHeight)); + // Inter is a geometric sans that reads loose at default tracking; pull it in + // slightly (more so on large headings). Other families keep normal tracking. + const isInter = fonts.baseFontFamily === "inter"; + root.style.setProperty( + "--editor-letter-spacing", + isInter ? "-0.006em" : "normal" + ); + root.style.setProperty( + "--editor-heading-letter-spacing", + isInter ? "-0.02em" : "normal" + ); + // Computed header sizes (based on base) root.style.setProperty("--editor-h1-size", `${baseSize * 2.25}px`); root.style.setProperty("--editor-h2-size", `${baseSize * 1.75}px`); diff --git a/src/main.tsx b/src/main.tsx index 34ef2fc5..15b90a1b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,5 +1,6 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import "@fontsource-variable/inter"; import "katex/dist/katex.min.css"; import App from "./App"; import "./App.css"; diff --git a/src/types/note.ts b/src/types/note.ts index 37addc1b..139e3860 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -17,7 +17,7 @@ export interface ThemeSettings { mode: "light" | "dark" | "system"; } -export type FontFamily = "system-sans" | "serif" | "monospace"; +export type FontFamily = "system-sans" | "inter" | "serif" | "monospace"; export type TextDirection = "auto" | "ltr" | "rtl"; export type EditorWidth = "narrow" | "normal" | "wide" | "full" | "custom"; From bcbac86c500b4fb9051d0bf51779062b6c2bce49 Mon Sep 17 00:00:00 2001 From: Gihan Rathnayake Date: Wed, 15 Jul 2026 13:18:29 +0800 Subject: [PATCH 3/8] feat: support viewing and editing plain-text/code files alongside notes Extends note discovery, search indexing, and the editor to recognize a built-in allowlist of code/text extensions (.go, .py, .js, etc.) in addition to .md, so users can keep code snippets alongside their markdown notes in the same folder. Non-markdown files get a syntax-highlighted read view with click-to-edit, reusing the existing raw-textarea "source mode" plumbing, and a new "New File..." action to create them from the sidebar. Co-Authored-By: Claude Sonnet 5 --- src-tauri/src/lib.rs | 428 ++++++++++++++++++++-- src/components/editor/Editor.tsx | 122 +++++- src/components/editor/codeHighlight.ts | 45 +++ src/components/editor/lowlight.ts | 67 ++-- src/components/layout/Sidebar.tsx | 40 ++ src/components/notes/FolderNameDialog.tsx | 4 +- src/components/notes/FolderTreeView.tsx | 10 +- src/components/notes/NoteList.tsx | 11 +- src/context/NotesContext.tsx | 32 ++ src/services/notes.ts | 14 +- src/types/note.ts | 2 + 11 files changed, 678 insertions(+), 97 deletions(-) create mode 100644 src/components/editor/codeHighlight.ts diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 04f54a80..f6dd8651 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ pub struct NoteMetadata { pub title: String, pub preview: String, pub modified: i64, + pub extension: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -42,6 +43,7 @@ pub struct Note { pub content: String, pub path: String, pub modified: i64, + pub extension: String, } // Theme color customization @@ -142,6 +144,7 @@ pub struct SearchResult { pub preview: String, pub modified: i64, pub score: f32, + pub extension: String, } // AI execution result @@ -170,6 +173,7 @@ pub struct SearchIndex { title_field: Field, content_field: Field, modified_field: Field, + extension_field: Field, } impl SearchIndex { @@ -180,10 +184,24 @@ impl SearchIndex { let title_field = schema_builder.add_text_field("title", TEXT | STORED); let content_field = schema_builder.add_text_field("content", TEXT | STORED); let modified_field = schema_builder.add_i64_field("modified", INDEXED | STORED); + let extension_field = schema_builder.add_text_field("extension", STRING | STORED); let schema = schema_builder.build(); - // Create or open index std::fs::create_dir_all(index_path)?; + + // If an index already exists on disk with an older schema (e.g. pre-dating the + // `extension` field), wipe and recreate it — every caller of `new` immediately + // calls `rebuild_index` afterward, so nothing is lost. Without this, opening an + // old-schema index and writing documents that reference `extension_field` would fail. + if let Ok(existing) = Index::open_in_dir(index_path) { + if existing.schema() != schema { + drop(existing); + std::fs::remove_dir_all(index_path)?; + std::fs::create_dir_all(index_path)?; + } + } + + // Create or open index let index = Index::create_in_dir(index_path, schema.clone()) .or_else(|_| Index::open_in_dir(index_path))?; @@ -203,10 +221,11 @@ impl SearchIndex { title_field, content_field, modified_field, + extension_field, }) } - fn index_note(&self, id: &str, title: &str, content: &str, modified: i64) -> Result<()> { + fn index_note(&self, id: &str, title: &str, content: &str, modified: i64, extension: &str) -> Result<()> { let mut writer = self.writer.lock().expect("search writer mutex"); // Delete existing document with this ID @@ -219,6 +238,7 @@ impl SearchIndex { self.title_field => title, self.content_field => content, self.modified_field => modified, + self.extension_field => extension, ))?; writer.commit()?; @@ -271,7 +291,13 @@ impl SearchIndex { .and_then(|v| v.as_i64()) .unwrap_or(0); - let preview = generate_preview(content); + let extension = doc + .get_first(self.extension_field) + .and_then(|v| v.as_str()) + .unwrap_or("md") + .to_string(); + + let preview = generate_preview_for(content, &extension); results.push(SearchResult { id, @@ -279,6 +305,7 @@ impl SearchIndex { preview, modified, score, + extension, }); } @@ -311,13 +338,15 @@ impl SearchIndex { .map(|d| d.as_secs() as i64) .unwrap_or(0); - let title = extract_title(&content); + let extension = extension_from_id(&id); + let title = extract_title_for(&content, &id, &extension); writer.add_document(doc!( self.id_field => id.as_str(), self.title_field => title, self.content_field => content.as_str(), self.modified_field => modified, + self.extension_field => extension.as_str(), ))?; } } @@ -499,6 +528,33 @@ fn generate_preview(content: &str) -> String { String::new() } +/// Extract a display title for a note, dispatching on extension: markdown notes use +/// `extract_title` (frontmatter/heading-aware); plain-text/code notes use the filename +/// stem instead, since a first-line-of-code title (e.g. "package main") isn't meaningful. +fn extract_title_for(content: &str, id: &str, ext: &str) -> String { + if is_markdown_ext(ext) { + extract_title(content) + } else { + let filename = id.rsplit('/').next().unwrap_or(id); + let stem = filename.strip_suffix(&format!(".{ext}")).unwrap_or(filename); + extract_title_from_id(stem) + } +} + +/// Generate a search/list preview for a note, dispatching on extension: markdown notes +/// strip frontmatter/formatting; plain-text/code notes use the raw first non-empty line. +fn generate_preview_for(content: &str, ext: &str) -> String { + if is_markdown_ext(ext) { + generate_preview(content) + } else { + content + .lines() + .find(|l| !is_effectively_empty(l)) + .map(|l| l.trim().chars().take(100).collect()) + .unwrap_or_default() + } +} + // Strip common markdown formatting from text fn strip_markdown(text: &str) -> String { let mut result = text.to_string(); @@ -599,6 +655,29 @@ fn strip_markdown(text: &str) -> String { /// Directories to exclude from note discovery and ID resolution (app-internal, always excluded). const EXCLUDED_DIRS: &[&str] = &[".git", ".scratch", ".obsidian", ".trash", "assets"]; +/// Extensions recognized as markdown notes (rendered in the rich-text editor). +const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown"]; + +/// Extensions recognized as plain-text/code notes (rendered in the syntax-highlighted view). +/// Keep in sync with the language modules registered in src/components/editor/lowlight.ts. +const TEXT_EXTENSIONS: &[&str] = &[ + "txt", "js", "jsx", "ts", "tsx", "py", "rs", "json", "sql", "css", "html", "xml", "sh", + "bash", "zsh", "yaml", "yml", "go", "java", "cpp", "cc", "cxx", "c", "h", "hpp", "swift", + "rb", "php", "diff", "patch", +]; + +fn is_markdown_ext(ext: &str) -> bool { + MARKDOWN_EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e)) +} + +fn is_text_ext(ext: &str) -> bool { + TEXT_EXTENSIONS.iter().any(|e| ext.eq_ignore_ascii_case(e)) +} + +fn is_supported_note_extension(ext: &str) -> bool { + is_markdown_ext(ext) || is_text_ext(ext) +} + /// Default user-configurable directories to ignore (common build/dependency folders). const DEFAULT_IGNORED_DIRS: &[&str] = &[ "node_modules", @@ -637,8 +716,11 @@ fn is_visible_notes_entry(entry: &walkdir::DirEntry, ignored_dirs: &[String]) -> true } -/// Convert an absolute file path to a note ID (relative path from notes root, no .md extension, POSIX separators). -/// Returns None if the path is outside the root, not a .md file, or in an excluded/ignored directory. +/// Convert an absolute file path to a note ID (POSIX separators). +/// Bare `.md` files get an extension-less ID for backward compatibility; every other +/// recognized extension (`.markdown`, code/text files) keeps its extension embedded in the ID. +/// Returns None if the path is outside the root, not a recognized extension, or in an +/// excluded/ignored directory. fn id_from_abs_path(notes_root: &Path, file_path: &Path, ignored_dirs: &[String]) -> Option { let rel = file_path.strip_prefix(notes_root).ok()?; @@ -653,16 +735,19 @@ fn id_from_abs_path(notes_root: &Path, file_path: &Path, ignored_dirs: &[String] } } - // Must be a .md file - if file_path.extension()?.to_str()? != "md" { - return None; - } - - // Build ID: relative path without .md suffix, using POSIX separators. - // Strip .md by converting to string and trimming (avoids with_extension - // which breaks on stems containing dots like "meeting.2024-01-15.md"). + let ext = file_path.extension()?.to_str()?; let rel_str = rel.to_str()?; - let id = rel_str.strip_suffix(".md")?.replace(std::path::MAIN_SEPARATOR, "/"); + + let id = if ext == "md" { + // Strip .md by converting to string and trimming (avoids with_extension + // which breaks on stems containing dots like "meeting.2024-01-15.md"). + rel_str.strip_suffix(".md")?.replace(std::path::MAIN_SEPARATOR, "/") + } else if is_supported_note_extension(ext) { + // Every other recognized extension (.markdown, .go, .py, ...) keeps its extension in the ID. + rel_str.replace(std::path::MAIN_SEPARATOR, "/") + } else { + return None; + }; if id.is_empty() { None @@ -672,6 +757,8 @@ fn id_from_abs_path(notes_root: &Path, file_path: &Path, ignored_dirs: &[String] } /// Convert a note ID to an absolute file path. Validates against path traversal. +/// If the ID's own extension is a recognized non-markdown extension, it's used as-is; +/// otherwise ".md" is appended (legacy behavior for extension-less markdown IDs). fn abs_path_from_id(notes_root: &Path, id: &str) -> Result { if id.contains('\\') { return Err("Invalid note ID: backslashes not allowed".to_string()); @@ -694,12 +781,22 @@ fn abs_path_from_id(notes_root: &Path, id: &str) -> Result { } } - // Append ".md" via OsString to avoid with_extension replacing dots in stems - // (e.g. "meeting.2024-01-15" would become "meeting.md" with with_extension) + let has_recognized_extension = rel + .extension() + .and_then(|e| e.to_str()) + .map(is_supported_note_extension) + .unwrap_or(false); + let joined = notes_root.join(rel); - let mut file_path_os = joined.into_os_string(); - file_path_os.push(".md"); - let file_path = PathBuf::from(file_path_os); + let file_path = if has_recognized_extension { + joined + } else { + // Append ".md" via OsString to avoid with_extension replacing dots in stems + // (e.g. "meeting.2024-01-15" would become "meeting.md" with with_extension) + let mut file_path_os = joined.into_os_string(); + file_path_os.push(".md"); + PathBuf::from(file_path_os) + }; if !file_path.starts_with(notes_root) { return Err("Invalid note ID: path escapes notes folder".to_string()); @@ -708,6 +805,17 @@ fn abs_path_from_id(notes_root: &Path, id: &str) -> Result { Ok(file_path) } +/// Extension embedded in a note ID, or "md" if the ID has no recognized extension +/// (the legacy extension-less markdown ID scheme). +fn extension_from_id(id: &str) -> String { + Path::new(id) + .extension() + .and_then(|e| e.to_str()) + .filter(|e| is_supported_note_extension(e)) + .unwrap_or("md") + .to_ascii_lowercase() +} + // Get app config file path (in app data directory) fn get_app_config_path(app: &AppHandle) -> Result { let app_data = app.path().app_data_dir()?; @@ -908,7 +1016,7 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str let path_clone = path.clone(); let discovered = tokio::task::spawn_blocking(move || { use walkdir::WalkDir; - let mut results: Vec<(String, String, String, i64)> = Vec::new(); + let mut results: Vec<(String, String, String, i64, String)> = Vec::new(); for entry in WalkDir::new(&path_clone) .max_depth(10) .into_iter() @@ -928,9 +1036,10 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64) .unwrap_or(0); - let title = extract_title(&content); - let preview = generate_preview(&content); - results.push((id, title, preview, modified)); + let ext = extension_from_id(&id); + let title = extract_title_for(&content, &id, &ext); + let preview = generate_preview_for(&content, &ext); + results.push((id, title, preview, modified, ext)); } } } @@ -941,11 +1050,12 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str let mut notes: Vec = discovered .into_iter() - .map(|(id, title, preview, modified)| NoteMetadata { + .map(|(id, title, preview, modified, extension)| NoteMetadata { id, title, preview, modified, + extension, }) .collect(); @@ -1013,12 +1123,16 @@ async fn read_note(id: String, state: State<'_, AppState>) -> Result, state: State<'_, AppState>) { let index = state.search_index.lock().expect("search index mutex"); if let Some(ref search_index) = *index { - let _ = search_index.index_note(&final_id, &display_title, &content, modified); + let _ = search_index.index_note(&final_id, &display_title, &content, modified, "md"); } } @@ -1271,6 +1428,198 @@ async fn create_note(target_folder: Option, state: State<'_, AppState>) content, path: file_path.to_string_lossy().into_owned(), modified, + extension: "md".to_string(), + }) +} + +#[tauri::command] +async fn create_file( + target_folder: Option, + filename: String, + state: State<'_, AppState>, +) -> Result { + let folder = { + let app_config = state.app_config.read().expect("app_config read lock"); + app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")? + }; + let folder_path = PathBuf::from(&folder); + + if filename.contains('/') || filename.contains('\\') { + return Err("Filename cannot contain path separators".to_string()); + } + + let (stem, ext) = filename + .rsplit_once('.') + .ok_or_else(|| "Please include a file extension (e.g. \"notes.go\")".to_string())?; + if !is_supported_note_extension(ext) { + return Err(format!( + "Unsupported file extension \".{}\"", + ext.to_ascii_lowercase() + )); + } + let ext = ext.to_ascii_lowercase(); + let sanitized_stem = sanitize_filename(stem); + + let build_id = |s: &str| -> String { + let leaf = if is_markdown_ext(&ext) { + s.to_string() + } else { + format!("{}.{}", s, ext) + }; + match target_folder.as_deref() { + Some(prefix) if !prefix.is_empty() => format!("{}/{}", prefix.trim_end_matches('/'), leaf), + _ => leaf, + } + }; + + let mut final_stem = sanitized_stem.clone(); + let mut final_id = build_id(&final_stem); + let mut counter = 1; + while abs_path_from_id(&folder_path, &final_id) + .map(|p| p.exists()) + .unwrap_or(false) + { + final_stem = format!("{}-{}", sanitized_stem, counter); + final_id = build_id(&final_stem); + counter += 1; + } + + let content = if is_markdown_ext(&ext) { + format!("# {}\n\n", extract_title_from_id(&final_stem)) + } else { + String::new() + }; + + let file_path = abs_path_from_id(&folder_path, &final_id)?; + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; + } + fs::write(&file_path, &content) + .await + .map_err(|e| e.to_string())?; + + // Derive the returned ID from the actual path via id_from_abs_path, so it can never + // drift from how list_notes/the watcher/the indexer will independently rediscover this file. + let ignored_dirs = { + let settings = state.settings.read().expect("settings read lock"); + get_effective_ignored_dirs(&settings) + }; + let derived_id = id_from_abs_path(&folder_path, &file_path, &ignored_dirs) + .ok_or_else(|| "Failed to create file".to_string())?; + + let modified = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let title = extract_title_for(&content, &derived_id, &ext); + + { + let index = state.search_index.lock().expect("search index mutex"); + if let Some(ref search_index) = *index { + let _ = search_index.index_note(&derived_id, &title, &content, modified, &ext); + } + } + + Ok(Note { + id: derived_id, + title, + content, + path: file_path.to_string_lossy().into_owned(), + modified, + extension: ext, + }) +} + +#[tauri::command] +async fn duplicate_note(id: String, state: State<'_, AppState>) -> Result { + let folder = { + let app_config = state.app_config.read().expect("app_config read lock"); + app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")? + }; + let folder_path = PathBuf::from(&folder); + + let source_path = abs_path_from_id(&folder_path, &id)?; + if !source_path.exists() { + return Err("Note not found".to_string()); + } + + let content = fs::read_to_string(&source_path) + .await + .map_err(|e| e.to_string())?; + let ext = extension_from_id(&id); + + let (dir_prefix, leaf) = match id.rfind('/') { + Some(pos) => (Some(id[..pos].to_string()), id[pos + 1..].to_string()), + None => (None, id.clone()), + }; + let stem = if is_markdown_ext(&ext) { + leaf + } else { + leaf.strip_suffix(&format!(".{ext}")).unwrap_or(&leaf).to_string() + }; + + let build_id = |s: &str| -> String { + let leaf = if is_markdown_ext(&ext) { + s.to_string() + } else { + format!("{}.{}", s, ext) + }; + match &dir_prefix { + Some(prefix) => format!("{}/{}", prefix, leaf), + None => leaf, + } + }; + + let mut new_stem = format!("{}-copy", stem); + let mut new_id = build_id(&new_stem); + let mut counter = 1; + while abs_path_from_id(&folder_path, &new_id) + .map(|p| p.exists()) + .unwrap_or(false) + { + new_stem = format!("{}-copy-{}", stem, counter); + new_id = build_id(&new_stem); + counter += 1; + } + + let dest_path = abs_path_from_id(&folder_path, &new_id)?; + fs::write(&dest_path, &content) + .await + .map_err(|e| e.to_string())?; + + let metadata = fs::metadata(&dest_path) + .await + .map_err(|e| e.to_string())?; + let modified = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + let title = extract_title_for(&content, &new_id, &ext); + + { + let index = state.search_index.lock().expect("search index mutex"); + if let Some(ref search_index) = *index { + let _ = search_index.index_note(&new_id, &title, &content, modified, &ext); + } + } + + Ok(Note { + id: new_id, + title, + content, + path: dest_path.to_string_lossy().into_owned(), + modified, + extension: ext, }) } @@ -1981,7 +2330,7 @@ async fn import_file_to_folder( { let index = state.search_index.lock().expect("search index mutex"); if let Some(ref search_index) = *index { - let _ = search_index.index_note(&final_id, &extracted_title, &content, modified); + let _ = search_index.index_note(&final_id, &extracted_title, &content, modified, "md"); } } @@ -1998,6 +2347,7 @@ async fn import_file_to_folder( title: extracted_title, preview, modified, + extension: "md".to_string(), }; // Update notes cache so fallback search sees the imported note immediately @@ -2060,7 +2410,7 @@ async fn fallback_search(query: &str, state: &State<'_, AppState>) -> Result = { + let cache_data: Vec<(String, String, String, i64, String)> = { let cache = state.notes_cache.read().expect("cache read lock"); cache .values() @@ -2070,6 +2420,7 @@ async fn fallback_search(query: &str, state: &State<'_, AppState>) -> Result) -> Result = Vec::new(); - for (id, title, preview, modified) in cache_data { + for (id, title, preview, modified, extension) in cache_data { let title_lower = title.to_lowercase(); let mut score = 0.0f32; @@ -2111,6 +2462,7 @@ async fn fallback_search(query: &str, state: &State<'_, AppState>) -> Result { match std::fs::read_to_string(path) { Ok(content) => { - let title = extract_title(&content); + let extension = extension_from_id(¬e_id); + let title = extract_title_for(&content, ¬e_id, &extension); let modified = std::fs::metadata(path) .ok() .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64) .unwrap_or(0); - let _ = search_index.index_note(¬e_id, &title, &content, modified); + let _ = search_index.index_note(¬e_id, &title, &content, modified, &extension); } Err(_) => { // File gone between event and read — treat as deletion @@ -2239,7 +2592,7 @@ fn setup_file_watcher( let mut watcher = watcher; - // Watch the notes folder recursively for .md files in subfolders + // Watch the notes folder recursively for recognized note files in subfolders watcher .watch(&folder_path, RecursiveMode::Recursive) .map_err(|e| e.to_string())?; @@ -3548,10 +3901,7 @@ fn try_select_in_notes_folder(app: &AppHandle, path: &Path) -> bool { fn is_markdown_extension(path: &Path) -> bool { path.extension() .and_then(|e| e.to_str()) - .map(|s| { - let lower = s.to_ascii_lowercase(); - lower == "md" || lower == "markdown" - }) + .map(is_markdown_ext) .unwrap_or(false) } @@ -3814,6 +4164,8 @@ pub fn run() { save_note, delete_note, create_note, + create_file, + duplicate_note, list_folders, create_folder, delete_folder, diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index dcf07161..1c616b70 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -22,6 +22,7 @@ import { TableKit } from "@tiptap/extension-table"; import { Markdown } from "@tiptap/markdown"; import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import { lowlight } from "./lowlight"; +import { highlightCode } from "./codeHighlight"; import { CodeBlockView } from "./CodeBlockView"; import { Extension, InputRule } from "@tiptap/core"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; @@ -522,10 +523,19 @@ export function Editor({ content: previewMode.content, path: previewMode.filePath, modified: previewMode.modified, + // Preview windows (opened via CLI/drag-drop/"Open With") only ever open markdown files. + extension: "md", } : null : (notesCtx?.currentNote ?? null); + // Plain-text/code notes (any recognized extension other than markdown) bypass the + // TipTap/markdown-manager pipeline entirely: they're read via the syntax-highlighted + //
 view and edited via the same raw textarea used for markdown "source mode".
+  const isPlainTextNote = !["md", "markdown"].includes(
+    (currentNote?.extension ?? "md").toLowerCase(),
+  );
+
   const saveNote = previewMode
     ? async (content: string, _noteId?: string) => {
         await previewMode.save(content);
@@ -1389,6 +1399,45 @@ export function Editor({
       return;
     }
 
+    // Plain-text/code notes never touch the TipTap document — sync sourceContent
+    // directly and skip all markdown-specific rename-detection/parsing below (rename
+    // detection doesn't apply since save_note never renames these notes by content).
+    if (isPlainTextNote) {
+      const isSameNote = currentNote.id === loadedNoteIdRef.current;
+      const isManualReload = reloadVersion !== lastReloadVersionRef.current;
+
+      if (isSameNote) {
+        if (isManualReload) {
+          lastReloadVersionRef.current = reloadVersion;
+          loadedModifiedRef.current = currentNote.modified;
+          setSourceContent(currentNote.content);
+        } else {
+          // Just a save - update refs but don't reload content
+          loadedModifiedRef.current = currentNote.modified;
+        }
+        return;
+      }
+
+      if (needsSaveRef.current) {
+        flushPendingSave();
+      }
+      if (sourceTimeoutRef.current) {
+        clearTimeout(sourceTimeoutRef.current);
+        sourceTimeoutRef.current = null;
+      }
+
+      const loadingNoteId = currentNote.id;
+      loadedNoteIdRef.current = loadingNoteId;
+      loadedModifiedRef.current = currentNote.modified;
+      lastReloadVersionRef.current = reloadVersion;
+      setSourceContent(currentNote.content);
+      scrollContainerRef.current?.scrollTo(0, 0);
+
+      // A freshly-created file opens straight into the edit textarea.
+      setSourceMode(consumePendingNewNote?.(loadingNoteId) ?? false);
+      return;
+    }
+
     const isSameNote = currentNote.id === loadedNoteIdRef.current;
 
     // Detect rename BEFORE flush to prevent stale-ID saves from creating duplicates.
@@ -1518,6 +1567,7 @@ export function Editor({
     flushPendingSave,
     reloadVersion,
     consumePendingNewNote,
+    isPlainTextNote,
   ]);
 
   // Scroll to top on mount (e.g., when returning from settings)
@@ -1772,7 +1822,7 @@ export function Editor({
         !e.shiftKey &&
         e.key.toLowerCase() === "f"
       ) {
-        if (!currentNote || !editor) return;
+        if (!currentNote || !editor || isPlainTextNote) return;
 
         const target = e.target as HTMLElement;
         const tagName = target.tagName.toLowerCase();
@@ -1797,7 +1847,7 @@ export function Editor({
     };
     document.addEventListener("keydown", handleKeyDown);
     return () => document.removeEventListener("keydown", handleKeyDown);
-  }, [editor, currentNote, openEditorSearch]);
+  }, [editor, currentNote, openEditorSearch, isPlainTextNote]);
 
   // Clear search on note switch
   useEffect(() => {
@@ -1853,14 +1903,14 @@ export function Editor({
 
   // Download handlers
   const handleDownloadPdf = useCallback(async () => {
-    if (!editor || !currentNote) return;
+    if (!editor || !currentNote || isPlainTextNote) return;
     try {
       await downloadPdf(editor, currentNote.title);
     } catch (error) {
       console.error("Failed to open print dialog:", error);
       toast.error("Failed to open print dialog");
     }
-  }, [editor, currentNote]);
+  }, [editor, currentNote, isPlainTextNote]);
 
   // Listen for Cmd+P print shortcut
   useEffect(() => {
@@ -1887,6 +1937,14 @@ export function Editor({
   // focus/scroll restoration happens in the useLayoutEffect below.
   const toggleSourceMode = useCallback(() => {
     if (!editor) return;
+
+    // Plain-text/code notes have no TipTap document to anchor against — content
+    // never round-trips through a parser, so this is a plain read/edit toggle.
+    if (isPlainTextNote) {
+      setSourceMode((prev) => !prev);
+      return;
+    }
+
     const container = scrollContainerRef.current;
 
     if (!sourceMode) {
@@ -1969,7 +2027,7 @@ export function Editor({
       }
       setSourceMode(false);
     }
-  }, [editor, sourceMode, sourceContent, getMarkdown]);
+  }, [editor, sourceMode, sourceContent, getMarkdown, isPlainTextNote]);
 
   // Restore focus and scroll position after source mode transitions.
   // useLayoutEffect runs synchronously after React commits DOM changes,
@@ -2042,6 +2100,17 @@ export function Editor({
     };
   }, [sourceMode, editor]);
 
+  // Auto-focus the textarea when entering edit mode for plain-text/code notes.
+  // (The transition-ref-based focus restoration above only fires for markdown
+  // source mode, since plain-text notes never populate sourceModeTransitionRef.)
+  useEffect(() => {
+    if (!isPlainTextNote || !sourceMode) return;
+    const textarea = scrollContainerRef.current?.querySelector(
+      "textarea",
+    ) as HTMLTextAreaElement | null;
+    textarea?.focus();
+  }, [isPlainTextNote, sourceMode]);
+
   // Listen for toggle-source-mode custom event (from App.tsx shortcut / command palette)
   useEffect(() => {
     const handler = () => toggleSourceMode();
@@ -2250,7 +2319,7 @@ export function Editor({
               
             
           )}
-          {currentNote && (
+          {currentNote && !isPlainTextNote && (
             
               
                 
@@ -2260,9 +2329,13 @@ export function Editor({
           {currentNote && (
             
               
@@ -2274,6 +2347,7 @@ export function Editor({
               
             
           )}
+          {!isPlainTextNote && (
           
             
             
           
+          )}
           {onSaveToFolder && (
             
               
         
-        {!focusMode && !sourceMode && (
+        {!focusMode && !sourceMode && !isPlainTextNote && (
           
         )}
         
+ ) : isPlainTextNote ? ( + /* Read-only syntax-highlighted view for plain-text/code notes; click to edit */ +
+
+                
+              
+
) : ( <> {searchOpen && ( diff --git a/src/components/editor/codeHighlight.ts b/src/components/editor/codeHighlight.ts new file mode 100644 index 00000000..77bd9b1a --- /dev/null +++ b/src/components/editor/codeHighlight.ts @@ -0,0 +1,45 @@ +import hljs from "highlight.js/lib/core"; +import { LANGUAGE_MODULES } from "./lowlight"; + +// Register the same language modules used for in-editor code blocks (see lowlight.ts) +// against highlight.js core directly, for the read-only view of plain-text/code notes. +for (const [names, module] of LANGUAGE_MODULES) { + const [primary, ...aliases] = names; + hljs.registerLanguage(primary, module); + if (aliases.length > 0) { + hljs.registerAliases(aliases, { languageName: primary }); + } +} + +// A handful of file extensions map to a different highlight.js language name +// than the extension itself (the rest fall through to `extension` unchanged, +// since lowlight.ts already registers those exact short names/aliases). +const EXTENSION_TO_LANGUAGE: Record = { + cc: "cpp", + cxx: "cpp", + hpp: "cpp", + h: "c", + patch: "diff", +}; + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Returns syntax-highlighted HTML for the given code, or escaped plain text on failure. */ +export function highlightCode(code: string, extension: string): string { + const language = EXTENSION_TO_LANGUAGE[extension.toLowerCase()] ?? extension.toLowerCase(); + if (hljs.getLanguage(language)) { + try { + return hljs.highlight(code, { language }).value; + } catch { + // Fall through to plain escaped text below. + } + } + return escapeHtml(code); +} diff --git a/src/components/editor/lowlight.ts b/src/components/editor/lowlight.ts index 1eac07d9..b679d38d 100644 --- a/src/components/editor/lowlight.ts +++ b/src/components/editor/lowlight.ts @@ -1,4 +1,5 @@ import { createLowlight } from "lowlight"; +import type { LanguageFn } from "highlight.js"; // Import only common languages to keep bundle small import javascript from "highlight.js/lib/languages/javascript"; @@ -22,43 +23,39 @@ import php from "highlight.js/lib/languages/php"; import diff from "highlight.js/lib/languages/diff"; import dockerfile from "highlight.js/lib/languages/dockerfile"; +// Shared language-module registry, reused by both the lowlight instance below +// (for in-editor code blocks) and codeHighlight.ts (for the plain-text/code note +// read view) so the two never drift out of sync. +export const LANGUAGE_MODULES: [string[], LanguageFn][] = [ + [["javascript", "js", "jsx"], javascript], + [["typescript", "ts", "tsx"], typescript], + [["python", "py"], python], + [["rust", "rs"], rust], + [["json"], json], + [["sql"], sql], + [["css"], css], + [["html", "xml"], xml], + [["bash", "sh", "shell", "zsh"], bash], + [["markdown", "md"], markdown], + [["yaml", "yml"], yaml], + [["go", "golang"], go], + [["java"], java], + [["cpp"], cpp], + [["c"], c], + [["swift"], swift], + [["ruby", "rb"], ruby], + [["php"], php], + [["diff"], diff], + [["dockerfile", "docker"], dockerfile], +]; + const lowlight = createLowlight(); -lowlight.register("javascript", javascript); -lowlight.register("js", javascript); -lowlight.register("jsx", javascript); -lowlight.register("typescript", typescript); -lowlight.register("ts", typescript); -lowlight.register("tsx", typescript); -lowlight.register("python", python); -lowlight.register("py", python); -lowlight.register("rust", rust); -lowlight.register("rs", rust); -lowlight.register("json", json); -lowlight.register("sql", sql); -lowlight.register("css", css); -lowlight.register("html", xml); -lowlight.register("xml", xml); -lowlight.register("bash", bash); -lowlight.register("sh", bash); -lowlight.register("shell", bash); -lowlight.register("zsh", bash); -lowlight.register("markdown", markdown); -lowlight.register("md", markdown); -lowlight.register("yaml", yaml); -lowlight.register("yml", yaml); -lowlight.register("go", go); -lowlight.register("golang", go); -lowlight.register("java", java); -lowlight.register("cpp", cpp); -lowlight.register("c", c); -lowlight.register("swift", swift); -lowlight.register("ruby", ruby); -lowlight.register("rb", ruby); -lowlight.register("php", php); -lowlight.register("diff", diff); -lowlight.register("dockerfile", dockerfile); -lowlight.register("docker", dockerfile); +for (const [names, module] of LANGUAGE_MODULES) { + for (const name of names) { + lowlight.register(name, module); + } +} export { lowlight }; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index d2ad1884..b4a81e7b 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -22,6 +22,7 @@ import { AddNoteIcon, FolderPlusIcon, NoteIcon, + CodeIcon, } from "../icons"; import { mod, shift, isMac } from "../../lib/platform"; import * as notesService from "../../services/notes"; @@ -34,6 +35,7 @@ interface SidebarProps { export function Sidebar({ onOpenSettings }: SidebarProps) { const { createNote, + createFile, createFolder, notes, search, @@ -48,6 +50,7 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { const [plusMenuOpen, setPlusMenuOpen] = useState(false); const [folderDialogOpen, setFolderDialogOpen] = useState(false); const [folderDialogParent, setFolderDialogParent] = useState(""); + const [fileDialogOpen, setFileDialogOpen] = useState(false); const [foldersEnabled, setFoldersEnabled] = useState(true); const [dragLabel, setDragLabel] = useState(null); const [dragCount, setDragCount] = useState(1); @@ -287,6 +290,25 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { [createFolder, folderDialogParent], ); + const handleNewFile = useCallback(() => { + setFileDialogOpen(true); + }, []); + + const handleFileDialogConfirm = useCallback( + async (filename: string) => { + try { + await createFile(filename); + setFileDialogOpen(false); + } catch (error) { + console.error("Failed to create file:", error); + toast.error( + error instanceof Error ? error.message : "Failed to create file", + ); + } + }, + [createFile], + ); + // Listen for create-new-folder event (from command palette / keyboard shortcut) useEffect(() => { const handleCreateFolder = () => { @@ -362,6 +384,13 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { {isMac ? "" : "+"}N + + + New File... + + + {/* New file dialog */} + {/* Drag overlay — floating label while dragging */} diff --git a/src/components/notes/FolderNameDialog.tsx b/src/components/notes/FolderNameDialog.tsx index c11ad25f..1d44e80c 100644 --- a/src/components/notes/FolderNameDialog.tsx +++ b/src/components/notes/FolderNameDialog.tsx @@ -19,6 +19,7 @@ interface FolderNameDialogProps { description?: string; confirmLabel?: string; defaultValue?: string; + placeholder?: string; } export function FolderNameDialog({ @@ -29,6 +30,7 @@ export function FolderNameDialog({ description = "Enter a name for your new folder", confirmLabel = "Create", defaultValue = "", + placeholder = "Folder name", }: FolderNameDialogProps) { const [name, setName] = useState(defaultValue); const inputRef = useRef(null); @@ -73,7 +75,7 @@ export function FolderNameDialog({ value={name} onChange={(e) => setName(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Folder name" + placeholder={placeholder} className="mt-1" /> diff --git a/src/components/notes/FolderTreeView.tsx b/src/components/notes/FolderTreeView.tsx index 3f7998a3..c68cd345 100644 --- a/src/components/notes/FolderTreeView.tsx +++ b/src/components/notes/FolderTreeView.tsx @@ -136,12 +136,18 @@ const FileItem = memo(function FileItem({ try { const folder = await notesService.getNotesFolder(); if (folder) { - await invoke("copy_to_clipboard", { text: `${folder}/${note.id}.md` }); + // Markdown IDs are extension-less; every other recognized extension + // (e.g. .go, .py) is already embedded in the ID. + const filepath = + note.extension.toLowerCase() === "md" + ? `${folder}/${note.id}.md` + : `${folder}/${note.id}`; + await invoke("copy_to_clipboard", { text: filepath }); } } catch (error) { console.error("Failed to copy filepath:", error); } - }, [note.id]); + }, [note.id, note.extension]); return ( diff --git a/src/components/notes/NoteList.tsx b/src/components/notes/NoteList.tsx index 3e761f39..770d9c8c 100644 --- a/src/components/notes/NoteList.tsx +++ b/src/components/notes/NoteList.tsx @@ -129,6 +129,7 @@ interface NoteItemWithMenuProps { title: string; preview?: string; modified: number; + extension: string; isSelected: boolean; isPinned: boolean; onSelect: (id: string) => void; @@ -144,6 +145,7 @@ const NoteItemWithMenu = memo(function NoteItemWithMenu({ title, preview, modified, + extension, isSelected, isPinned, onSelect, @@ -166,13 +168,16 @@ const NoteItemWithMenu = memo(function NoteItemWithMenu({ try { const folder = await notesService.getNotesFolder(); if (folder) { - const filepath = `${folder}/${id}.md`; + // Markdown IDs are extension-less; every other recognized extension + // (e.g. .go, .py) is already embedded in the ID. + const filepath = + extension.toLowerCase() === "md" ? `${folder}/${id}.md` : `${folder}/${id}`; await invoke("copy_to_clipboard", { text: filepath }); } } catch (error) { console.error("Failed to copy filepath:", error); } - }, [id]); + }, [id, extension]); return ( @@ -302,6 +307,7 @@ export function NoteList({ title: r.title, preview: r.preview, modified: r.modified, + extension: r.extension, })); } return notes; @@ -410,6 +416,7 @@ export function NoteList({ title={item.title} preview={item.preview} modified={item.modified} + extension={item.extension} isSelected={selectedNoteId === item.id} isPinned={pinnedIds.has(item.id)} onSelect={selectNote} diff --git a/src/context/NotesContext.tsx b/src/context/NotesContext.tsx index 7ad2ea11..9d81501b 100644 --- a/src/context/NotesContext.tsx +++ b/src/context/NotesContext.tsx @@ -33,6 +33,7 @@ interface NotesDataContextValue { interface NotesActionsContextValue { selectNote: (id: string) => Promise; createNote: () => Promise; + createFile: (filename: string) => Promise; consumePendingNewNote: (id: string) => boolean; saveNote: (content: string, noteId?: string) => Promise; deleteNote: (id: string) => Promise; @@ -176,6 +177,34 @@ export function NotesProvider({ children }: { children: ReactNode }) { } }, [refreshNotes]); + const createFile = useCallback( + async (filename: string) => { + // Derive target folder from the selected note's parent path + let targetFolder: string | undefined; + if (selectedNoteIdRef.current) { + const lastSlash = selectedNoteIdRef.current.lastIndexOf("/"); + if (lastSlash > 0) { + targetFolder = selectedNoteIdRef.current.substring(0, lastSlash); + } + } + const note = await notesService.createFile(filename, targetFolder); + selectRequestIdRef.current += 1; + pendingNewNoteIdRef.current = note.id; + // Mark as recently saved to ignore file-change events from our own creation + recentlySavedRef.current.add(note.id); + await refreshNotes(); + setCurrentNote(note); + setSelectedNoteId(note.id); + // Clear search when creating a new file + setSearchQuery(""); + setSearchResults([]); + setTimeout(() => { + recentlySavedRef.current.delete(note.id); + }, 1000); + }, + [refreshNotes], + ); + const consumePendingNewNote = useCallback((id: string) => { if (pendingNewNoteIdRef.current !== id) { pendingNewNoteIdRef.current = null; @@ -559,6 +588,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { preview: note.preview, modified: note.modified, score: 0, + extension: note.extension, })); // Show instant local matches immediately; clear stale results if none match. @@ -715,6 +745,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { () => ({ selectNote, createNote, + createFile, consumePendingNewNote, saveNote, deleteNote, @@ -737,6 +768,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { [ selectNote, createNote, + createFile, consumePendingNewNote, saveNote, deleteNote, diff --git a/src/services/notes.ts b/src/services/notes.ts index c4632627..cb8aa6e8 100644 --- a/src/services/notes.ts +++ b/src/services/notes.ts @@ -29,6 +29,10 @@ export async function createNote(targetFolder?: string): Promise { return invoke("create_note", { targetFolder: targetFolder ?? null }); } +export async function createFile(filename: string, targetFolder?: string): Promise { + return invoke("create_file", { targetFolder: targetFolder ?? null, filename }); +} + export async function listFolders(): Promise { return invoke("list_folders"); } @@ -54,14 +58,7 @@ export async function moveFolder(path: string, targetParent: string): Promise { - // Read the original note, then create a new one in the same folder - const original = await readNote(id); - const lastSlash = id.lastIndexOf("/"); - const folder = lastSlash > 0 ? id.substring(0, lastSlash) : undefined; - const newNote = await createNote(folder); - // Save with the original content (title will be extracted from content) - const duplicatedContent = original.content.replace(/^# (.+)$/m, (_, title) => `# ${title} (Copy)`); - return saveNote(newNote.id, duplicatedContent || original.content); + return invoke("duplicate_note", { id }); } export async function getSettings(): Promise { @@ -88,6 +85,7 @@ export interface SearchResult { preview: string; modified: number; score: number; + extension: string; } export async function searchNotes(query: string): Promise { diff --git a/src/types/note.ts b/src/types/note.ts index 139e3860..14b03ad0 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -3,6 +3,7 @@ export interface NoteMetadata { title: string; preview: string; modified: number; + extension: string; } export interface Note { @@ -11,6 +12,7 @@ export interface Note { content: string; path: string; modified: number; + extension: string; } export interface ThemeSettings { From e035de8aafd1bad4892d2509a9f5bf7447a49339 Mon Sep 17 00:00:00 2001 From: Gihan Rathnayake Date: Wed, 29 Jul 2026 13:59:24 +0800 Subject: [PATCH 4/8] feat: integrate Find & Replace, sidebar resize, and platform fixes from upstream v1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports several features and fixes from erictli/scratch's v1.0.0 release that don't conflict with this fork's Linux window-controls fix, Inter font option, or plain-text/code file editing support: - Find & Replace with Replace/Replace All actions, platform-aware shortcut (⌥⌘F on macOS, Ctrl+H elsewhere) (upstream #180) - Drag-to-resize sidebar width, persisted per-folder (upstream #170) - Linux window-controls config (tauri.linux.conf.json, visible:true + show()/set_focus()), complementary to this fork's existing GDK_BACKEND=x11 fix — different root cause, same symptom (upstream #160) - macOS autocorrect/spellcheck support via WebKit defaults (upstream #150) - Windows native titlebar layout fix (upstream #163) Co-Authored-By: Claude Sonnet 5 --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 + src-tauri/src/lib.rs | 31 +++ src-tauri/tauri.linux.conf.json | 16 ++ src/App.css | 11 + src/App.tsx | 13 +- src/components/editor/Editor.tsx | 122 +++++++++-- src/components/editor/SearchToolbar.tsx | 148 ++++++++++--- src/components/icons/index.tsx | 46 ++++ src/components/layout/FolderPicker.tsx | 3 +- src/components/layout/Sidebar.tsx | 8 +- src/components/layout/SidebarResizeHandle.tsx | 196 ++++++++++++++++++ src/components/settings/SettingsPage.tsx | 10 +- src/context/ThemeContext.tsx | 56 +++++ src/lib/platform.ts | 3 + src/lib/sidebar.ts | 6 + src/types/note.ts | 1 + 17 files changed, 608 insertions(+), 66 deletions(-) create mode 100644 src-tauri/tauri.linux.conf.json create mode 100644 src/components/layout/SidebarResizeHandle.tsx create mode 100644 src/lib/sidebar.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d8b8c6c3..f14f26e8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -10,6 +10,7 @@ dependencies = [ "base64 0.22.1", "chrono", "notify", + "objc2-foundation", "open", "regex", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1afac9d7..eaa91665 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,3 +33,6 @@ regex = "1" walkdir = "2" tauri-plugin-single-instance = "2" chrono = "0.4" + +[target.'cfg(target_os = "macos")'.dependencies] +objc2-foundation = { version = "0.3", features = ["NSUserDefaults", "NSString"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f6dd8651..fe109c89 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -124,6 +124,9 @@ pub struct Settings { pub interface_zoom: Option, #[serde(rename = "customEditorWidthPx")] pub custom_editor_width_px: Option, + /// Custom sidebar width in px; `None` means the default width is used. + #[serde(rename = "sidebarWidthPx")] + pub sidebar_width_px: Option, #[serde(rename = "ollamaModel")] pub ollama_model: Option, #[serde(rename = "foldersEnabled")] @@ -4028,8 +4031,36 @@ fn handle_cli_args(app: &AppHandle, args: &[String], cwd: &str) -> bool { opened_preview } +// On macOS, WKWebView reads per-app preferences from NSUserDefaults to decide +// whether to show the spelling underline and apply auto-correct in contenteditable +// regions. These keys default to off for new bundle IDs, which is why a fresh +// Tauri app gets neither the red underline nor auto-replace even when the HTML +// `spellcheck`/`autocorrect` attributes are set. Seed missing WebKit preferences +// before the webview is constructed; existing user-toggled values still win. +#[cfg(target_os = "macos")] +fn enable_webview_spellcheck_defaults() { + use objc2_foundation::{NSString, NSUserDefaults}; + + let keys = [ + "WebContinuousSpellCheckingEnabled", + "WebGrammarCheckingEnabled", + "WebAutomaticSpellingCorrectionEnabled", + ]; + + let defaults = NSUserDefaults::standardUserDefaults(); + for key in keys { + let key = NSString::from_str(key); + if defaults.objectForKey(&key).is_none() { + defaults.setBool_forKey(true, &key); + } + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + #[cfg(target_os = "macos")] + enable_webview_spellcheck_defaults(); + let app = tauri::Builder::default() // Single-instance: forward CLI args from subsequent launches to the running instance .plugin(tauri_plugin_single_instance::init(|app, args, cwd| { diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json new file mode 100644 index 00000000..f6509440 --- /dev/null +++ b/src-tauri/tauri.linux.conf.json @@ -0,0 +1,16 @@ +{ + "app": { + "windows": [ + { + "title": "Scratch", + "width": 1080, + "height": 720, + "minWidth": 600, + "minHeight": 400, + "resizable": true, + "decorations": true, + "visible": true + } + ] + } +} diff --git a/src/App.css b/src/App.css index 4dcee316..b1afd51f 100644 --- a/src/App.css +++ b/src/App.css @@ -124,6 +124,12 @@ body, overscroll-behavior: none; } +/* Windows keeps its native title bar (no in-app titlebar spacer); a top border + separates the app content from the themed caption. */ +.platform-windows #root { + border-top: 1px solid var(--color-border); +} + /* macOS transparent title bar support */ .titlebar-drag-region { -webkit-app-region: drag; @@ -1062,6 +1068,11 @@ table.not-prose th { transition: none !important; } +/* Suppress sidebar transition during drag resize */ +.sidebar-no-transition [data-sidebar] { + transition: none !important; +} + /* Print styles for clean PDF export */ @page { margin: 0.75in; diff --git a/src/App.tsx b/src/App.tsx index 6bbf132b..0e5224e9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,6 +6,8 @@ import { listen } from "@tauri-apps/api/event"; import { GitProvider } from "./context/GitContext"; import { TooltipProvider, Toaster } from "./components/ui"; import { Sidebar } from "./components/layout/Sidebar"; +import { SidebarResizeHandle } from "./components/layout/SidebarResizeHandle"; +import { SIDEBAR_DEFAULT_PX } from "./lib/sidebar"; import { Editor } from "./components/editor/Editor"; import type { Editor as TiptapEditor } from "@tiptap/react"; import { FolderPicker } from "./components/layout/FolderPicker"; @@ -29,6 +31,7 @@ import { import { getCurrentWindow } from "@tauri-apps/api/window"; import * as aiService from "./services/ai"; import type { AiProvider } from "./services/ai"; +import { isMac, isWindows } from "./lib/platform"; // Detect preview mode from URL search params function getWindowMode(): { @@ -472,9 +475,11 @@ function AppContent() { <>
+ {sidebarVisible && !focusMode && }
{ - const isMac = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent); - document.documentElement.classList.add( - isMac ? "platform-mac" : "platform-other", - ); + const os = isMac ? "mac" : isWindows ? "windows" : "linux"; + document.documentElement.classList.add(`platform-${os}`); }, []); // Check for app updates on startup (folder mode only) diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index 1c616b70..924766c1 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -38,7 +38,7 @@ import { openUrl } from "@tauri-apps/plugin-opener"; import { invoke, convertFileSrc } from "@tauri-apps/api/core"; import { join } from "@tauri-apps/api/path"; import { toast } from "sonner"; -import { mod, alt, shift, isMac } from "../../lib/platform"; +import { mod, alt, shift, isMac, isWindows } from "../../lib/platform"; // Prepend https:// if no protocol is present function normalizeUrl(url: string): string { @@ -586,6 +586,8 @@ export function Editor({ // Search state const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [replaceQuery, setReplaceQuery] = useState(""); + const [isReplaceOpen, setIsReplaceOpen] = useState(false); const [searchMatches, setSearchMatches] = useState< Array<{ from: number; to: number }> >([]); @@ -1142,6 +1144,9 @@ export function Editor({ attributes: { class: "prose prose-lg dark:prose-invert max-w-3xl mx-auto focus:outline-none min-h-full px-6 pt-8 pb-24", + spellcheck: "true", + autocorrect: "on", + autocapitalize: "sentences", }, // Serialize copied text as markdown instead of plain text clipboardTextSerializer: (slice) => { @@ -1315,6 +1320,55 @@ export function Editor({ setSearchQuery(query); }, []); + const replaceCurrent = useCallback((replaceText: string) => { + if (!editor || !searchQuery.trim()) return; + + // Recompute from current doc state to avoid stale debounced matches. + const currentMatches = findMatches(searchQuery, editor); + if (currentMatches.length === 0) return; + + const safeIndex = Math.min(currentMatchIndex, currentMatches.length - 1); + const match = currentMatches[safeIndex]; + if (!match) return; + + editor.view.dispatch( + editor.state.tr.insertText(replaceText, match.from, match.to) + ); + + const newMatches = findMatches(searchQuery, editor); + setSearchMatches(newMatches); + + if (newMatches.length > 0) { + // Move to the first match after the replaced range. + const nextPos = match.from + replaceText.length; + const nextIndex = newMatches.findIndex((m) => m.from >= nextPos); + const resolvedIndex = nextIndex === -1 ? 0 : nextIndex; + setCurrentMatchIndex(resolvedIndex); + updateSearchDecorations(newMatches, resolvedIndex, editor); + } else { + setCurrentMatchIndex(0); + updateSearchDecorations([], 0, editor); + } + }, [editor, searchQuery, currentMatchIndex, findMatches, updateSearchDecorations]); + + const replaceAll = useCallback((replaceText: string) => { + if (!editor || !searchQuery) return; + const currentMatches = findMatches(searchQuery, editor); + if (currentMatches.length === 0) return; + + const tr = editor.state.tr; + for (let i = currentMatches.length - 1; i >= 0; i--) { + const match = currentMatches[i]; + tr.insertText(replaceText, match.from, match.to); + } + editor.view.dispatch(tr); + + const newMatches = findMatches(searchQuery, editor); + setSearchMatches(newMatches); + setCurrentMatchIndex(0); + updateSearchDecorations(newMatches, 0, editor); + }, [editor, searchQuery, findMatches, updateSearchDecorations]); + // Debounced search effect useEffect(() => { if (!searchQuery.trim()) { @@ -1814,23 +1868,32 @@ export function Editor({ }); }, []); - // Cmd+F to open search (works when document/editor area is focused) + // Cmd/Ctrl+F to open search, ⌥⌘F (macOS) / Ctrl+H to open replace + // (works when document/editor area is focused) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if ( + const openFind = (e.metaKey || e.ctrlKey) && !e.shiftKey && - e.key.toLowerCase() === "f" - ) { + !e.altKey && + e.key.toLowerCase() === "f"; + // Cmd+H is reserved by macOS (Hide), so replace uses the platform + // convention: ⌥⌘F on macOS, Ctrl+H elsewhere. e.code is checked on + // macOS because ⌥ changes e.key to a special character ("ƒ"). + const openReplace = isMac + ? e.metaKey && e.altKey && !e.shiftKey && e.code === "KeyF" + : e.ctrlKey && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "h"; + if (openFind || openReplace) { if (!currentNote || !editor || isPlainTextNote) return; const target = e.target as HTMLElement; const tagName = target.tagName.toLowerCase(); - // Don't intercept if user is in an input/textarea (except the editor itself) + // Don't intercept if user is in an input/textarea (except the editor itself or search toolbar) if ( (tagName === "input" || tagName === "textarea") && - !target.closest(".ProseMirror") + !target.closest(".ProseMirror") && + !target.closest(".search-toolbar-container") ) { return; } @@ -1842,6 +1905,9 @@ export function Editor({ // Open search for the editor e.preventDefault(); + if (openReplace) { + setIsReplaceOpen(true); + } openEditorSearch(); } }; @@ -1854,6 +1920,8 @@ export function Editor({ if (currentNote?.id) { setSearchOpen(false); setSearchQuery(""); + setReplaceQuery(""); + setIsReplaceOpen(false); setSearchMatches([]); setCurrentMatchIndex(0); // Clear decorations @@ -2148,10 +2216,12 @@ export function Editor({ if (previewMode) { return (
-
+ {!isWindows && ( +
+ )}
@@ -2163,10 +2233,12 @@ export function Editor({ if (notesCtx?.selectedNoteId) { return (
-
+ {!isWindows && ( +
+ )}
@@ -2178,10 +2250,12 @@ export function Editor({ return (
{/* Drag region */} -
+ {!isWindows && ( +
+ )}
@@ -2515,6 +2589,8 @@ export function Editor({ onClose={() => { setSearchOpen(false); setSearchQuery(""); + setReplaceQuery(""); + setIsReplaceOpen(false); setSearchMatches([]); setCurrentMatchIndex(0); // Clear decorations and refocus editor @@ -2527,6 +2603,12 @@ export function Editor({ searchMatches.length === 0 ? 0 : currentMatchIndex + 1 } totalMatches={searchMatches.length} + replaceQuery={replaceQuery} + onReplaceChange={setReplaceQuery} + onReplace={() => replaceCurrent(replaceQuery)} + onReplaceAll={() => replaceAll(replaceQuery)} + isReplaceOpen={isReplaceOpen} + onToggleReplace={() => setIsReplaceOpen(!isReplaceOpen)} />
diff --git a/src/components/editor/SearchToolbar.tsx b/src/components/editor/SearchToolbar.tsx index 58ac474c..81f128e0 100644 --- a/src/components/editor/SearchToolbar.tsx +++ b/src/components/editor/SearchToolbar.tsx @@ -1,6 +1,14 @@ import { useEffect, type RefObject } from "react"; import { Input, IconButton } from "../ui"; -import { ArrowUpIcon, ArrowDownIcon, XIcon } from "../icons"; +import { + ArrowUpIcon, + ArrowDownIcon, + XIcon, + ChevronDownIcon, + ChevronRightIcon, + ReplaceIcon, + ReplaceAllIcon, +} from "../icons"; import { shift } from "../../lib/platform"; interface SearchToolbarProps { @@ -12,6 +20,13 @@ interface SearchToolbarProps { currentMatch: number; totalMatches: number; inputRef: RefObject; + // Replace functionality props + replaceQuery: string; + onReplaceChange: (value: string) => void; + onReplace: () => void; + onReplaceAll: () => void; + isReplaceOpen: boolean; + onToggleReplace: () => void; } export function SearchToolbar({ @@ -23,6 +38,12 @@ export function SearchToolbar({ currentMatch, totalMatches, inputRef, + replaceQuery, + onReplaceChange, + onReplace, + onReplaceAll, + isReplaceOpen, + onToggleReplace, }: SearchToolbarProps) { // Auto-focus input on mount useEffect(() => { @@ -49,43 +70,108 @@ export function SearchToolbar({ } }; - return ( -
- onChange(e.target.value)} - placeholder="Find in note..." - className="w-55 h-8 text-sm" - onKeyDown={handleKeyDown} - /> - - - {totalMatches > 0 ? `${currentMatch}/${totalMatches}` : "Not found"} - + const handleReplaceKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + if (e.metaKey || e.ctrlKey) { + onReplaceAll(); + } else { + onReplace(); + } + } else if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } else if (e.key === "Tab") { + e.stopPropagation(); + } + }; -
+ return ( +
+ {/* First Row: Find */} +
- + {isReplaceOpen ? ( + + ) : ( + + )} - - - + onChange(e.target.value)} + placeholder="Find in note..." + className="flex-1 h-8 text-sm" + onKeyDown={handleKeyDown} + /> - - - + + {totalMatches > 0 ? `${currentMatch}/${totalMatches}` : "0/0"} + + +
+ + + + + + + + + + + +
+ + {/* Second Row: Replace */} + {isReplaceOpen && ( +
+ onReplaceChange(e.target.value)} + placeholder="Replace with..." + className="flex-1 h-8 text-sm" + onKeyDown={handleReplaceKeyDown} + /> + +
+ + + + + + + +
+
+ )}
); } diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx index c2647688..3e25c02b 100644 --- a/src/components/icons/index.tsx +++ b/src/components/icons/index.tsx @@ -1518,3 +1518,49 @@ export function OllamaIcon({ ); } + +export function ReplaceIcon({ className = "w-4.5 h-4.5" }: IconProps) { + return ( + + + + + + + + + + ); +} + +export function ReplaceAllIcon({ className = "w-4.5 h-4.5" }: IconProps) { + return ( + + + + + + + + + + + + ); +} diff --git a/src/components/layout/FolderPicker.tsx b/src/components/layout/FolderPicker.tsx index ab822cf1..4e2127a8 100644 --- a/src/components/layout/FolderPicker.tsx +++ b/src/components/layout/FolderPicker.tsx @@ -2,6 +2,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import { useNotes } from "../../context/NotesContext"; import { useTheme } from "../../context/ThemeContext"; import { Button } from "../ui"; +import { isWindows } from "../../lib/platform"; export function FolderPicker() { const { setNotesFolder } = useNotes(); @@ -28,7 +29,7 @@ export function FolderPicker() { return (
{/* Draggable title bar area */} -
+ {!isWindows &&
}
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index b4a81e7b..05e6edf9 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -24,7 +24,7 @@ import { NoteIcon, CodeIcon, } from "../icons"; -import { mod, shift, isMac } from "../../lib/platform"; +import { mod, shift, isMac, isWindows } from "../../lib/platform"; import * as notesService from "../../services/notes"; import { FolderNameDialog } from "../notes/FolderNameDialog"; @@ -332,10 +332,10 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { onDragEnd={handleDragEnd} onDragCancel={() => setDragLabel(null)} > -
+
{/* Drag region */} -
-
+ {!isWindows &&
} +
Notes
diff --git a/src/components/layout/SidebarResizeHandle.tsx b/src/components/layout/SidebarResizeHandle.tsx new file mode 100644 index 00000000..8492b22a --- /dev/null +++ b/src/components/layout/SidebarResizeHandle.tsx @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTheme } from "../../context/ThemeContext"; +import { cn } from "../../lib/utils"; +import { SIDEBAR_DEFAULT_PX, SIDEBAR_MIN_PX, SIDEBAR_MAX_PX } from "../../lib/sidebar"; + +/** Pointer movement below this many px counts as a click, not a drag. */ +const DRAG_THRESHOLD_PX = 3; + +/** Applies the `--sidebar-width` override; `null` falls back to the CSS default. */ +function applyWidthVar(px: number | null) { + if (px === null) { + document.documentElement.style.removeProperty("--sidebar-width"); + } else { + document.documentElement.style.setProperty("--sidebar-width", `${px}px`); + } +} + +/** + * Drag handle rendered on the right edge of the sidebar. + * Supports pointer drag to resize, keyboard arrow keys (Shift = large step), and double-click to reset width. + */ +export function SidebarResizeHandle() { + const { sidebarWidthPx, setSidebarWidthPx, setSidebarWidthLive } = useTheme(); + + const [isDragging, setIsDragging] = useState(false); + const [currentWidth, setCurrentWidth] = useState(0); + + const dragState = useRef<{ + startX: number; + initialWidth: number; + moved: boolean; + } | null>(null); + + // Latest persisted width, readable from the unmount cleanup below + const persistedWidthRef = useRef(sidebarWidthPx); + useEffect(() => { + persistedWidthRef.current = sidebarWidthPx; + }, [sidebarWidthPx]); + + // If unmounted mid-drag (e.g. focus mode toggled via shortcut), restore the + // transition class and the persisted width so the live drag value doesn't stick + useEffect( + () => () => { + if (dragState.current) { + document.documentElement.classList.remove("sidebar-no-transition"); + applyWidthVar(persistedWidthRef.current); + } + }, + [], + ); + + /** Captures the pointer and records the drag start position and initial width. */ + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + (e.currentTarget as HTMLElement).focus(); + + const initialWidth = + sidebarWidthPx ?? (e.currentTarget as HTMLElement).parentElement!.offsetWidth; + dragState.current = { + startX: e.clientX, + initialWidth, + moved: false, + }; + setIsDragging(true); + setCurrentWidth(initialWidth); + document.documentElement.classList.add("sidebar-no-transition"); + }, + [sidebarWidthPx], + ); + + /** Updates the CSS variable live during drag without persisting. */ + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + if (!dragState.current) return; + const { startX, initialWidth, moved } = dragState.current; + + const delta = e.clientX - startX; + if (!moved && Math.abs(delta) < DRAG_THRESHOLD_PX) return; + dragState.current.moved = true; + + const newWidth = initialWidth + delta; + const clamped = Math.round( + Math.min(Math.max(newWidth, SIDEBAR_MIN_PX), SIDEBAR_MAX_PX), + ); + + setSidebarWidthLive(clamped); + setCurrentWidth(clamped); + }, + [setSidebarWidthLive], + ); + + /** Releases pointer capture and persists the final width to settings. */ + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + if (!dragState.current) return; + const { moved } = dragState.current; + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + document.documentElement.classList.remove("sidebar-no-transition"); + + // A stationary click must not persist: the two clicks of a double-click + // reset would otherwise issue settings writes that race the reset itself + if (moved) { + setSidebarWidthPx(currentWidth); + } + dragState.current = null; + setIsDragging(false); + }, + [currentWidth, setSidebarWidthPx], + ); + + /** Aborts the drag on pointer cancel, restoring the last persisted width. */ + const handlePointerCancel = useCallback(() => { + if (!dragState.current) return; + document.documentElement.classList.remove("sidebar-no-transition"); + applyWidthVar(sidebarWidthPx); + dragState.current = null; + setIsDragging(false); + }, [sidebarWidthPx]); + + /** Resets the sidebar to its default width (removes the override). */ + const handleDoubleClick = useCallback(() => { + setSidebarWidthPx(null); + }, [setSidebarWidthPx]); + + /** Adjusts width with arrow keys (16 px step, 64 px with Shift); Home/End jump to bounds. */ + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const step = e.shiftKey ? 64 : 16; + const base = + sidebarWidthPx ?? (e.currentTarget as HTMLElement).parentElement!.offsetWidth; + let next: number | null = null; + + switch (e.key) { + case "ArrowLeft": + next = base - step; + break; + case "ArrowRight": + next = base + step; + break; + case "Home": + next = SIDEBAR_MIN_PX; + break; + case "End": + next = SIDEBAR_MAX_PX; + break; + default: + return; + } + + e.preventDefault(); + setSidebarWidthPx(next); + }, + [sidebarWidthPx, setSidebarWidthPx], + ); + + return ( +
+
+ {isDragging && ( +
+
+ {Math.round(currentWidth)}px +
+
+ )} +
+ ); +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 7237d95a..a6bde4ce 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -13,7 +13,7 @@ import { AppearanceSettingsSection } from "./EditorSettingsSection"; import { ShortcutsSettingsSection } from "./ShortcutsSettingsSection"; import { AboutSettingsSection } from "./AboutSettingsSection"; import { ToolsSettingsSection } from "./ToolsSettingsSection"; -import { mod, isMac } from "../../lib/platform"; +import { mod, isMac, isWindows } from "../../lib/platform"; interface SettingsPageProps { onBack: () => void; @@ -77,10 +77,10 @@ export function SettingsPage({ onBack }: SettingsPageProps) { {/* Sidebar - matches main Notes sidebar */}
{/* Drag region */} -
+ {!isWindows &&
} {/* Header with back button and Settings title */} -
+
{/* Drag region */} -
+ {!isWindows &&
} {/* Content - centered with max width */}
-
+
{activeTab === "general" && } {activeTab === "tools" && } {activeTab === "editor" && } diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index 8898a9df..26895223 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -8,6 +8,7 @@ import { } from "react"; import { invoke } from "@tauri-apps/api/core"; import { getSettings, updateSettings } from "../services/notes"; +import { SIDEBAR_MIN_PX, SIDEBAR_MAX_PX } from "../lib/sidebar"; import type { ThemeSettings, EditorFontSettings, @@ -114,6 +115,9 @@ interface ThemeContextType { customEditorWidthPx: number; setCustomEditorWidthPx: (px: number) => void; setEditorMaxWidthLive: (value: string) => void; + sidebarWidthPx: number | null; + setSidebarWidthPx: (px: number | null) => void; + setSidebarWidthLive: (px: number) => void; customColorsLight: CustomColors; customColorsDark: CustomColors; setCustomColor: (mode: "light" | "dark", key: ThemeColorKey, value: string) => void; @@ -201,6 +205,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { const [customEditorWidthPx, setCustomEditorWidthPxState] = useState( DEFAULT_CUSTOM_WIDTH_PX ); + const [sidebarWidthPx, setSidebarWidthPxState] = useState(null); const [customColorsLight, setCustomColorsLightState] = useState({}); const [customColorsDark, setCustomColorsDarkState] = useState({}); const [isInitialized, setIsInitialized] = useState(false); @@ -256,6 +261,13 @@ export function ThemeProvider({ children }: ThemeProviderProps) { ) { setCustomEditorWidthPxState(settings.customEditorWidthPx); } + if ( + typeof settings.sidebarWidthPx === "number" && + settings.sidebarWidthPx >= SIDEBAR_MIN_PX && + settings.sidebarWidthPx <= SIDEBAR_MAX_PX + ) { + setSidebarWidthPxState(settings.sidebarWidthPx); + } if (settings.customColorsLight) { setCustomColorsLightState(settings.customColorsLight); } @@ -343,6 +355,15 @@ export function ThemeProvider({ children }: ThemeProviderProps) { applyLayoutCSSVariables(editorWidth, customEditorWidthPx); }, [editorWidth, customEditorWidthPx]); + // Apply sidebar width CSS variable whenever it changes (null = no override, fallback to 16rem) + useEffect(() => { + if (sidebarWidthPx === null) { + document.documentElement.style.removeProperty("--sidebar-width"); + } else { + document.documentElement.style.setProperty("--sidebar-width", `${sidebarWidthPx}px`); + } + }, [sidebarWidthPx]); + // Apply interface zoom whenever it changes (suppress transitions during zoom) useEffect(() => { const root = document.documentElement; @@ -392,6 +413,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { setEditorWidthState("normal"); setInterfaceZoomState(1.0); setCustomEditorWidthPxState(DEFAULT_CUSTOM_WIDTH_PX); + setSidebarWidthPxState(null); setCustomColorsLightState({}); setCustomColorsDarkState({}); try { @@ -403,6 +425,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { editorWidth: "normal", interfaceZoom: 1.0, customEditorWidthPx: undefined, + sidebarWidthPx: undefined, customColorsLight: undefined, customColorsDark: undefined, }); @@ -475,6 +498,31 @@ export function ThemeProvider({ children }: ThemeProviderProps) { } }, []); + /** + * Persists the clamped sidebar width to settings. + * Pass `null` to remove the override and fall back to the CSS default. + */ + const setSidebarWidthPx = useCallback(async (px: number | null) => { + if (px === null) { + setSidebarWidthPxState(null); + try { + const settings = await getSettings(); + await updateSettings({ ...settings, sidebarWidthPx: undefined }); + } catch (error) { + console.error("Failed to reset sidebar width:", error); + } + } else { + const clamped = Math.round(Math.min(Math.max(px, SIDEBAR_MIN_PX), SIDEBAR_MAX_PX)); + setSidebarWidthPxState(clamped); + try { + const settings = await getSettings(); + await updateSettings({ ...settings, sidebarWidthPx: clamped }); + } catch (error) { + console.error("Failed to save sidebar width:", error); + } + } + }, []); + // Apply custom color CSS variable overrides whenever theme or colors change useEffect(() => { const root = document.documentElement; @@ -563,6 +611,11 @@ export function ThemeProvider({ children }: ThemeProviderProps) { document.documentElement.style.setProperty("--editor-max-width", value); }, []); + /** Updates `--sidebar-width` CSS variable immediately during drag without writing to settings. */ + const setSidebarWidthLive = useCallback((px: number) => { + document.documentElement.style.setProperty("--sidebar-width", `${px}px`); + }, []); + // Don't render until initialized to prevent flash if (!isInitialized) { return null; @@ -588,6 +641,9 @@ export function ThemeProvider({ children }: ThemeProviderProps) { customEditorWidthPx, setCustomEditorWidthPx, setEditorMaxWidthLive, + sidebarWidthPx, + setSidebarWidthPx, + setSidebarWidthLive, customColorsLight, customColorsDark, setCustomColor, diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 2ff37c23..e8d79553 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -8,6 +8,9 @@ export const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent); +export const isWindows = + typeof navigator !== "undefined" && /Windows/.test(navigator.userAgent); + /** Modifier key symbol/label */ export const mod = isMac ? "⌘" : "Ctrl"; export const alt = isMac ? "⌥" : "Alt"; diff --git a/src/lib/sidebar.ts b/src/lib/sidebar.ts new file mode 100644 index 00000000..97eea3dd --- /dev/null +++ b/src/lib/sidebar.ts @@ -0,0 +1,6 @@ +/** Default sidebar width in pixels when no override is set (16rem, the former `w-64`). */ +export const SIDEBAR_DEFAULT_PX = 256; +/** Minimum allowed sidebar width in pixels. */ +export const SIDEBAR_MIN_PX = 180; +/** Maximum allowed sidebar width in pixels. */ +export const SIDEBAR_MAX_PX = 800; diff --git a/src/types/note.ts b/src/types/note.ts index 14b03ad0..58963be8 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -55,6 +55,7 @@ export interface Settings { textDirection?: TextDirection; editorWidth?: EditorWidth; customEditorWidthPx?: number; + sidebarWidthPx?: number; defaultNoteName?: string; interfaceZoom?: number; ollamaModel?: string; From 63294134a543952b3db4685b23f790ddf6b088c6 Mon Sep 17 00:00:00 2001 From: Gihan Rathnayake Date: Wed, 29 Jul 2026 14:05:51 +0800 Subject: [PATCH 5/8] fix: rename productName to avoid Linux package name collision The Fedora/Ubuntu repos both ship an unrelated package named "scratch" (MIT's educational Scratch), which Tauri's linux bundler collides with since it derives the deb/rpm package name from productName lowercased. Rename to "Scratch Notes" so local builds install as scratch-notes without conflicting; this also covers any future CI-built releases since they read the same config. Co-Authored-By: Claude Sonnet 5 --- src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b4e2ab1b..b0a642b5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "Scratch", + "productName": "Scratch Notes", "version": "0.10.0", "identifier": "com.scratch.app", "build": { From b15eefdb102bb9fb82158ecee367c3b405819b82 Mon Sep 17 00:00:00 2001 From: Gihan Rathnayake Date: Wed, 29 Jul 2026 15:21:07 +0800 Subject: [PATCH 6/8] feat: add Repose Light color preset Adds a one-click "Apply Repose Light" preset in the light theme's color editor, based on Monkeytype's Repose Light theme (cream background, muted sage accent). Reuses the existing custom-color override system, so it's fully reversible via the existing "Reset all" control. bg-muted/bg-emphasis/border are derived from the theme's text color at the same opacities the default theme uses; selection reuses the default light theme's highlight color for visual consistency across themes. Co-Authored-By: Claude Sonnet 5 --- .../settings/EditorSettingsSection.tsx | 42 +++++++++++++------ src/context/ThemeContext.tsx | 18 ++++++++ 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/components/settings/EditorSettingsSection.tsx b/src/components/settings/EditorSettingsSection.tsx index 32ffab75..13eb3d9a 100644 --- a/src/components/settings/EditorSettingsSection.tsx +++ b/src/components/settings/EditorSettingsSection.tsx @@ -1,6 +1,7 @@ import { useTheme, defaultThemeColors, + reposeLightColors, fontFamilyMap, } from "../../context/ThemeContext"; import { Button, CodeCopyButton, IconButton, Input, Select } from "../ui"; @@ -492,19 +493,34 @@ function ColorsExpandable({ {label} - {hasAnyCustom && ( - - )} +
+ {mode === "light" && ( + + )} + {hasAnyCustom && ( + + )} +
{(() => { diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index 26895223..39332970 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -79,6 +79,24 @@ const defaultThemeColors: Record<"light" | "dark", Record export { defaultThemeColors }; +// A curated color preset users can apply on top of the light theme, based on +// Monkeytype's "Repose Light" theme. bg-muted/bg-emphasis/border are derived +// from the theme's text color at the same opacities the default theme uses; +// selection reuses the default light theme's highlight color for consistency. +const reposeLightColors: Record = { + bg: "#efead0", + "bg-secondary": "#dbd6c4", + "bg-muted": "rgba(51, 53, 56, 0.06)", + "bg-emphasis": "rgba(51, 53, 56, 0.09)", + text: "#333538", + "text-muted": "#8f8e84", + border: "rgba(51, 53, 56, 0.08)", + accent: "#5f605e", + selection: "rgba(250, 204, 21, 0.4)", +}; + +export { reposeLightColors }; + // Normalize any CSS color string (hex, rgb(), rgba(), hsl(), named) to an RGB // triple by letting the browser parse it via getComputedStyle. function parseCssColorToRgb(value: string): [number, number, number] | null { From 1190223f663515e26576f5d9251e0e121d887330 Mon Sep 17 00:00:00 2001 From: Gihan Rathnayake Date: Wed, 29 Jul 2026 16:17:45 +0800 Subject: [PATCH 7/8] feat: add Repose Dark color preset Dark counterpart to the Repose Light preset, based on Monkeytype's Repose Dark theme. Generalizes the color-editor preset button to pick the right preset for the mode being edited instead of only offering one for light. bg-muted/bg-emphasis/border are tinted from the theme's (light) text color at the same opacities the default dark theme uses; selection reuses the default dark theme's highlight color for consistency. Co-Authored-By: Claude Sonnet 5 --- .../settings/EditorSettingsSection.tsx | 35 +++++++++++-------- src/context/ThemeContext.tsx | 18 ++++++++++ 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/components/settings/EditorSettingsSection.tsx b/src/components/settings/EditorSettingsSection.tsx index 13eb3d9a..8eae5909 100644 --- a/src/components/settings/EditorSettingsSection.tsx +++ b/src/components/settings/EditorSettingsSection.tsx @@ -2,6 +2,7 @@ import { useTheme, defaultThemeColors, reposeLightColors, + reposeDarkColors, fontFamilyMap, } from "../../context/ThemeContext"; import { Button, CodeCopyButton, IconButton, Input, Select } from "../ui"; @@ -494,20 +495,26 @@ function ColorsExpandable({ {label}
- {mode === "light" && ( - - )} + {(() => { + const preset = + mode === "light" ? reposeLightColors : reposeDarkColors; + const presetLabel = + mode === "light" ? "Apply Repose Light" : "Apply Repose Dark"; + return ( + + ); + })()} {hasAnyCustom && ( + + +
@@ -1112,6 +1187,7 @@ export function Editor({ TaskItem.configure({ nested: true, }), + MultiHighlight.configure({ multicolor: true }), TableKit.configure({ table: { resizable: false, @@ -2626,8 +2702,16 @@ export function Editor({ if (!clickPos) return; - // Set the selection to the clicked position - editor.chain().focus().setTextSelection(clickPos.pos).run(); + // Only collapse to the click point if it falls outside the + // current selection — right-clicking inside an existing + // selection should preserve it, so native/OS Copy and Cut + // still have something to act on. + const { from, to } = editor.state.selection; + const clickIsInsideSelection = + clickPos.pos >= from && clickPos.pos <= to; + if (!clickIsInsideSelection) { + editor.chain().focus().setTextSelection(clickPos.pos).run(); + } // Check if we're in a table after updating selection if (!editor.isActive("table")) return; diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx index 3e25c02b..e74d0dbc 100644 --- a/src/components/icons/index.tsx +++ b/src/components/icons/index.tsx @@ -158,6 +158,26 @@ export function StrikethroughIcon({ className = "w-4.5 h-4.5" }: IconProps) { ); } +export function HighlighterIcon({ className = "w-4.5 h-4.5" }: IconProps) { + return ( + + + + + + + + ); +} + export function Heading1Icon({ className = "w-4.5 h-4.5" }: IconProps) { return ( "text-muted": "#78716c", border: "rgba(28, 25, 23, 0.08)", accent: "#1c1917", - selection: "rgba(250, 204, 21, 0.4)", + selection: "rgba(59, 130, 246, 0.35)", + highlight: "rgba(250, 204, 21, 0.4)", }, dark: { bg: "rgb(22, 20, 19)", @@ -73,7 +74,8 @@ const defaultThemeColors: Record<"light" | "dark", Record "text-muted": "#a8a29e", border: "rgba(250, 249, 249, 0.07)", accent: "#fafaf9", - selection: "rgba(253, 224, 71, 0.35)", + selection: "rgba(96, 165, 250, 0.3)", + highlight: "rgba(253, 224, 71, 0.35)", }, }; @@ -82,7 +84,7 @@ export { defaultThemeColors }; // A curated color preset users can apply on top of the light theme, based on // Monkeytype's "Repose Light" theme. bg-muted/bg-emphasis/border are derived // from the theme's text color at the same opacities the default theme uses; -// selection reuses the default light theme's highlight color for consistency. +// selection/highlight reuse the default light theme's values for consistency. const reposeLightColors: Record = { bg: "#efead0", "bg-secondary": "#dbd6c4", @@ -92,7 +94,8 @@ const reposeLightColors: Record = { "text-muted": "#8f8e84", border: "rgba(51, 53, 56, 0.08)", accent: "#5f605e", - selection: "rgba(250, 204, 21, 0.4)", + selection: "rgba(59, 130, 246, 0.35)", + highlight: "rgba(250, 204, 21, 0.4)", }; export { reposeLightColors }; @@ -110,7 +113,8 @@ const reposeDarkColors: Record = { "text-muted": "#8f8e84", border: "rgba(214, 210, 188, 0.07)", accent: "#d6d2bc", - selection: "rgba(253, 224, 71, 0.35)", + selection: "rgba(96, 165, 250, 0.3)", + highlight: "rgba(253, 224, 71, 0.35)", }; export { reposeDarkColors }; @@ -566,7 +570,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { const defaults = defaultThemeColors[resolvedTheme]; const keys: ThemeColorKey[] = [ "bg", "bg-secondary", "bg-muted", "bg-emphasis", - "text", "text-muted", "border", "accent", "selection", + "text", "text-muted", "border", "accent", "selection", "highlight", ]; for (const key of keys) { const value = activeColors[key] ?? defaults[key]; diff --git a/src/types/note.ts b/src/types/note.ts index 58963be8..6d2a4ebf 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -40,7 +40,8 @@ export type ThemeColorKey = | "text-muted" | "border" | "accent" - | "selection"; + | "selection" + | "highlight"; // Partial map of color overrides (hex strings) export type CustomColors = Partial>;