feat: rename note by double-clicking its tab — inline edit, updates filename + wiki-links
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
35
.claude-done
Normal file
35
.claude-done
Normal file
@@ -0,0 +1,35 @@
|
||||
# Rename Note Tab — Done Summary
|
||||
|
||||
## What was built
|
||||
Double-click a tab title to rename the note. The tab shows an inline text input (Enter saves, Escape cancels). Renaming updates the note's title, filename, H1 heading, frontmatter `title:` field, and all wiki links (`[[OldName]]` → `[[NewName]]`) across the entire vault.
|
||||
|
||||
## Commits on `task/rename-note-tab`
|
||||
1. `caa26b6` — design: rename-note-tab wireframes (`design/rename-note-tab.pen`)
|
||||
2. `68dfab4` — feat: add `rename_note` Tauri command with wiki link updates (Rust backend)
|
||||
3. `5a8eb6a` — feat: rename note via double-click on tab — frontend wiring
|
||||
4. `ee00c2c` — test: Playwright e2e tests for rename tab feature
|
||||
|
||||
## Architecture decisions
|
||||
- **Slug generation**: title → lowercase, non-alphanumeric chars replaced with hyphens, leading/trailing hyphens stripped. Same logic in Rust backend and TypeScript mock.
|
||||
- **Wiki link matching**: regex matches both title-based (`[[Old Title]]`) and path-stem-based (`[[note/old-title]]`) references, preserving pipe aliases (`[[Old Title|display]]` → `[[New Title|display]]`).
|
||||
- **State flow**: `onRenameTab` flows from TabBar → Editor → App → `useNoteActions.handleRenameNote` → Tauri backend → `useVaultLoader.replaceEntry` (updates entries + allContent atomically).
|
||||
- **H1 + frontmatter**: The rename updates both the first `# Heading` line and the `title:` frontmatter field if present.
|
||||
- **Toast feedback**: Shows "Renamed" or "Renamed — updated N wiki links" on success, "Failed to rename note" on error.
|
||||
|
||||
## Code health
|
||||
- **TabBar.tsx**: Improved from CC 23→18 by extracting `TabItem` component and `tabStyle` helper.
|
||||
- **Rust `vault.rs`**: Passed code health after extracting helpers (`build_wikilink_pattern`, `WikilinkReplacement` struct, `collect_md_files`, etc.) to keep CC under thresholds.
|
||||
- **useNoteActions.ts**: Pre-existing CC=37 (threshold 9) increased to 42. Extracted `performRename` and `buildRenamedEntry` as module-level functions. Proper fix requires splitting the monolithic hook (separate concern).
|
||||
- **App.tsx / useVaultLoader.ts**: Minor LOC/CC increases on already-above-threshold functions.
|
||||
|
||||
## Test results
|
||||
- `pnpm test` — 119/119 passed
|
||||
- `cargo test` — 147/147 passed (including 7 new rename tests)
|
||||
- `npx vite build` — success
|
||||
- Playwright e2e — 2/2 rename-tab tests pass
|
||||
|
||||
## Visual verification
|
||||
Tested on `localhost:5173` via Playwright:
|
||||
- Double-click → inline input appears with current title selected
|
||||
- Type new name → Enter → tab title updates, note list updates, breadcrumb updates, toast shows "Renamed"
|
||||
- Double-click → type → Escape → title reverts, no changes made
|
||||
1221
design/rename-note-tab.pen
Normal file
1221
design/rename-note-tab.pen
Normal file
File diff suppressed because it is too large
Load Diff
80
e2e/rename-tab.spec.ts
Normal file
80
e2e/rename-tab.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('rename tab by double-clicking', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Screenshot initial state
|
||||
await page.screenshot({ path: 'test-results/rename-01-initial.png', fullPage: true })
|
||||
|
||||
// Click a note in the list using text content
|
||||
await page.getByText('Deprecated Workflow').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Screenshot: note opened as tab
|
||||
await page.screenshot({ path: 'test-results/rename-02-note-opened.png', fullPage: true })
|
||||
|
||||
// Find the tab title in the tab bar and double-click it
|
||||
const tabTitle = page.locator('.group span.truncate').first()
|
||||
await expect(tabTitle).toBeVisible({ timeout: 5000 })
|
||||
const originalTitle = await tabTitle.textContent()
|
||||
console.log(`Original title: "${originalTitle}"`)
|
||||
|
||||
await tabTitle.dblclick()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Screenshot: editing mode
|
||||
await page.screenshot({ path: 'test-results/rename-03-editing.png', fullPage: true })
|
||||
|
||||
// Verify input appeared
|
||||
const editInput = page.locator('.group input')
|
||||
await expect(editInput).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Type new name
|
||||
await editInput.fill('Renamed Test Note')
|
||||
await page.screenshot({ path: 'test-results/rename-04-typing.png', fullPage: true })
|
||||
|
||||
// Press Enter to save
|
||||
await editInput.press('Enter')
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Screenshot: after rename
|
||||
await page.screenshot({ path: 'test-results/rename-05-saved.png', fullPage: true })
|
||||
|
||||
// Verify tab title changed
|
||||
const newTabTitle = page.locator('.group span.truncate').first()
|
||||
await expect(newTabTitle).toHaveText('Renamed Test Note')
|
||||
})
|
||||
|
||||
test('cancel rename with Escape', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Open a note
|
||||
await page.getByText('Deprecated Workflow').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const tabTitle = page.locator('.group span.truncate').first()
|
||||
const originalTitle = await tabTitle.textContent()
|
||||
|
||||
// Double-click to edit
|
||||
await tabTitle.dblclick()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
const editInput = page.locator('.group input')
|
||||
await expect(editInput).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Type something different
|
||||
await editInput.fill('Will Be Cancelled')
|
||||
|
||||
// Press Escape to cancel
|
||||
await editInput.press('Escape')
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Screenshot: after cancel
|
||||
await page.screenshot({ path: 'test-results/rename-06-cancelled.png', fullPage: true })
|
||||
|
||||
// Verify title unchanged
|
||||
const afterTitle = page.locator('.group span.truncate').first()
|
||||
await expect(afterTitle).toHaveText(originalTitle!)
|
||||
})
|
||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -1904,6 +1904,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"gray_matter",
|
||||
"log",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -28,6 +28,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
futures-util = "0.3"
|
||||
base64 = "0.22"
|
||||
regex = "1"
|
||||
dirs = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -5,7 +5,7 @@ pub mod vault;
|
||||
|
||||
use ai_chat::{AiChatRequest, AiChatResponse};
|
||||
use git::{GitCommit, ModifiedFile};
|
||||
use vault::VaultEntry;
|
||||
use vault::{VaultEntry, RenameResult};
|
||||
use frontmatter::FrontmatterValue;
|
||||
|
||||
#[tauri::command]
|
||||
@@ -68,6 +68,11 @@ fn save_image(vault_path: String, filename: String, data: String) -> Result<Stri
|
||||
vault::save_image(&vault_path, &filename, &data)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn rename_note(vault_path: String, old_path: String, new_title: String) -> Result<RenameResult, String> {
|
||||
vault::rename_note(&vault_path, &old_path, &new_title)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
vault::purge_trash(&vault_path)
|
||||
@@ -108,6 +113,7 @@ pub fn run() {
|
||||
get_note_content,
|
||||
update_frontmatter,
|
||||
delete_frontmatter_property,
|
||||
rename_note,
|
||||
get_file_history,
|
||||
get_modified_files,
|
||||
get_file_diff,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use gray_matter::engine::YAML;
|
||||
use gray_matter::Matter;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
@@ -802,6 +803,200 @@ pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result<String
|
||||
Ok(target_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// Result of a rename operation
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct RenameResult {
|
||||
/// New absolute file path after rename
|
||||
pub new_path: String,
|
||||
/// Number of other files updated (wiki link replacements)
|
||||
pub updated_files: usize,
|
||||
}
|
||||
|
||||
/// Convert a title to a filename slug (lowercase, hyphens, no special chars).
|
||||
fn title_to_slug(title: &str) -> String {
|
||||
title
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect::<String>()
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<&str>>()
|
||||
.join("-")
|
||||
}
|
||||
|
||||
/// Update the first H1 heading in markdown content to a new title.
|
||||
fn update_h1_title(content: &str, new_title: &str) -> String {
|
||||
let has_h1 = content.lines().any(|l| l.trim().starts_with("# "));
|
||||
if !has_h1 {
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
let result: Vec<String> = content.lines().map(|l| {
|
||||
if l.trim().starts_with("# ") { format!("# {}", new_title) } else { l.to_string() }
|
||||
}).collect();
|
||||
|
||||
let joined = result.join("\n");
|
||||
if content.ends_with('\n') && !joined.ends_with('\n') {
|
||||
format!("{}\n", joined)
|
||||
} else {
|
||||
joined
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a regex that matches wiki links referencing old title or path stem.
|
||||
fn build_wikilink_pattern(old_title: &str, old_path_stem: &str) -> Option<Regex> {
|
||||
let pattern_str = format!(
|
||||
r"\[\[(?:{}|{})(\|[^\]]*?)?\]\]",
|
||||
regex::escape(old_title),
|
||||
regex::escape(old_path_stem),
|
||||
);
|
||||
Regex::new(&pattern_str).ok()
|
||||
}
|
||||
|
||||
/// Check if a path is a vault markdown file eligible for wikilink replacement.
|
||||
fn is_replaceable_md_file(path: &Path, exclude: &Path) -> bool {
|
||||
path.is_file()
|
||||
&& path != exclude
|
||||
&& path.extension().is_some_and(|ext| ext == "md")
|
||||
}
|
||||
|
||||
/// Replace wikilink references in a single file's content. Returns updated content if changed.
|
||||
fn replace_wikilinks_in_content(content: &str, re: &Regex, new_title: &str) -> Option<String> {
|
||||
if !re.is_match(content) {
|
||||
return None;
|
||||
}
|
||||
let replaced = re.replace_all(content, |caps: ®ex::Captures| {
|
||||
match caps.get(1) {
|
||||
Some(pipe) => format!("[[{}{}]]", new_title, pipe.as_str()),
|
||||
None => format!("[[{}]]", new_title),
|
||||
}
|
||||
});
|
||||
if replaced != content { Some(replaced.into_owned()) } else { None }
|
||||
}
|
||||
|
||||
/// Parameters for a vault-wide wikilink replacement.
|
||||
struct WikilinkReplacement<'a> {
|
||||
vault_path: &'a Path,
|
||||
old_title: &'a str,
|
||||
new_title: &'a str,
|
||||
old_path_stem: &'a str,
|
||||
exclude_path: &'a Path,
|
||||
}
|
||||
|
||||
/// Collect all .md file paths in vault eligible for wikilink replacement.
|
||||
fn collect_md_files(vault_path: &Path, exclude: &Path) -> Vec<std::path::PathBuf> {
|
||||
WalkDir::new(vault_path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.into_path())
|
||||
.filter(|p| is_replaceable_md_file(p, exclude))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Replace wiki link references across all vault markdown files.
|
||||
fn update_wikilinks_in_vault(params: &WikilinkReplacement) -> usize {
|
||||
let re = match build_wikilink_pattern(params.old_title, params.old_path_stem) {
|
||||
Some(r) => r,
|
||||
None => return 0,
|
||||
};
|
||||
|
||||
let files = collect_md_files(params.vault_path, params.exclude_path);
|
||||
files.iter().filter(|path| {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
match replace_wikilinks_in_content(&content, &re, params.new_title) {
|
||||
Some(new_content) => fs::write(path, &new_content).is_ok(),
|
||||
None => false,
|
||||
}
|
||||
}).count()
|
||||
}
|
||||
|
||||
/// Check if frontmatter contains a `title:` key.
|
||||
fn frontmatter_has_title_key(content: &str) -> bool {
|
||||
if !content.starts_with("---\n") {
|
||||
return false;
|
||||
}
|
||||
content[4..].split("\n---").next()
|
||||
.map(|fm| fm.lines().any(|l| {
|
||||
let t = l.trim_start();
|
||||
t.starts_with("title:") || t.starts_with("\"title\":")
|
||||
}))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Update H1 and optionally the `title:` frontmatter field in content.
|
||||
fn update_note_title_in_content(content: &str, new_title: &str) -> String {
|
||||
let mut updated = update_h1_title(content, new_title);
|
||||
if frontmatter_has_title_key(content) {
|
||||
let value = crate::frontmatter::FrontmatterValue::String(new_title.to_string());
|
||||
if let Ok(c) = update_frontmatter_content(&updated, "title", Some(value)) {
|
||||
updated = c;
|
||||
}
|
||||
}
|
||||
updated
|
||||
}
|
||||
|
||||
/// Strip vault prefix and .md suffix to get the relative path stem (e.g., "project/weekly-review").
|
||||
fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
|
||||
abs_path
|
||||
.strip_prefix(vault_prefix)
|
||||
.unwrap_or(abs_path)
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(abs_path)
|
||||
}
|
||||
|
||||
/// Rename a note: update its title, rename the file, and update wiki links across the vault.
|
||||
pub fn rename_note(vault_path: &str, old_path: &str, new_title: &str) -> Result<RenameResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
let old_file = Path::new(old_path);
|
||||
|
||||
if !old_file.exists() {
|
||||
return Err(format!("File does not exist: {}", old_path));
|
||||
}
|
||||
let new_title = new_title.trim();
|
||||
if new_title.is_empty() {
|
||||
return Err("New title cannot be empty".to_string());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(old_file)
|
||||
.map_err(|e| format!("Failed to read {}: {}", old_path, e))?;
|
||||
let old_filename = old_file.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let old_title = extract_title(&content, &old_filename);
|
||||
|
||||
if old_title == new_title {
|
||||
return Ok(RenameResult { new_path: old_path.to_string(), updated_files: 0 });
|
||||
}
|
||||
|
||||
// Update content (H1 + frontmatter title)
|
||||
let updated_content = update_note_title_in_content(&content, new_title);
|
||||
|
||||
// Compute new path and write file
|
||||
let parent_dir = old_file.parent().ok_or("Cannot determine parent directory")?;
|
||||
let new_file = parent_dir.join(format!("{}.md", title_to_slug(new_title)));
|
||||
let new_path_str = new_file.to_string_lossy().to_string();
|
||||
|
||||
fs::write(&new_file, &updated_content)
|
||||
.map_err(|e| format!("Failed to write {}: {}", new_path_str, e))?;
|
||||
if old_file != new_file {
|
||||
fs::remove_file(old_file)
|
||||
.map_err(|e| format!("Failed to remove old file {}: {}", old_path, e))?;
|
||||
}
|
||||
|
||||
// Update wikilinks across the vault
|
||||
let vault_prefix = format!("{}/", vault.to_string_lossy());
|
||||
let old_path_stem = to_path_stem(old_path, &vault_prefix);
|
||||
let updated_files = update_wikilinks_in_vault(&WikilinkReplacement {
|
||||
vault_path: vault, old_title: &old_title, new_title, old_path_stem, exclude_path: &new_file,
|
||||
});
|
||||
|
||||
Ok(RenameResult { new_path: new_path_str, updated_files })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1785,4 +1980,137 @@ References:
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(deleted[0].contains("old.md"));
|
||||
}
|
||||
|
||||
// --- rename_note tests ---
|
||||
|
||||
#[test]
|
||||
fn test_title_to_slug() {
|
||||
assert_eq!(title_to_slug("Weekly Review"), "weekly-review");
|
||||
assert_eq!(title_to_slug("My Note! "), "my-note");
|
||||
assert_eq!(title_to_slug("Hello World"), "hello-world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_h1_title() {
|
||||
let content = "---\nIs A: Note\n---\n# Old Title\n\nContent here.\n";
|
||||
let updated = update_h1_title(content, "New Title");
|
||||
assert!(updated.contains("# New Title"));
|
||||
assert!(!updated.contains("# Old Title"));
|
||||
assert!(updated.contains("Content here."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_basic() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/weekly-review.md", "---\nIs A: Note\n---\n# Weekly Review\n\nContent here.\n");
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
).unwrap();
|
||||
|
||||
assert!(result.new_path.ends_with("sprint-retrospective.md"));
|
||||
assert!(!old_path.exists());
|
||||
assert!(Path::new(&result.new_path).exists());
|
||||
|
||||
let new_content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert!(new_content.contains("# Sprint Retrospective"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_updates_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/weekly-review.md", "---\nIs A: Note\n---\n# Weekly Review\n\nContent.\n");
|
||||
create_test_file(vault, "note/other.md", "---\nIs A: Note\n---\n# Other\n\nSee [[Weekly Review]] for details.\n");
|
||||
create_test_file(vault, "project/my-project.md", "---\nIs A: Project\nRelated to:\n - \"[[Weekly Review]]\"\n---\n# My Project\n");
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retrospective",
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 2);
|
||||
|
||||
let other_content = fs::read_to_string(vault.join("note/other.md")).unwrap();
|
||||
assert!(other_content.contains("[[Sprint Retrospective]]"));
|
||||
assert!(!other_content.contains("[[Weekly Review]]"));
|
||||
|
||||
let project_content = fs::read_to_string(vault.join("project/my-project.md")).unwrap();
|
||||
assert!(project_content.contains("[[Sprint Retrospective]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_same_title_noop() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/my-note.md", "# My Note\n\nContent.\n");
|
||||
|
||||
let old_path = vault.join("note/my-note.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"My Note",
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(result.new_path, old_path.to_str().unwrap());
|
||||
assert_eq!(result.updated_files, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_empty_title_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/test.md", "# Test\n");
|
||||
|
||||
let old_path = vault.join("note/test.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
" ",
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_preserves_pipe_alias_in_wikilinks() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/weekly-review.md", "# Weekly Review\n");
|
||||
create_test_file(vault, "note/ref.md", "# Ref\n\nSee [[Weekly Review|my review]] for info.\n");
|
||||
|
||||
let old_path = vault.join("note/weekly-review.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"Sprint Retro",
|
||||
).unwrap();
|
||||
|
||||
assert_eq!(result.updated_files, 1);
|
||||
let ref_content = fs::read_to_string(vault.join("note/ref.md")).unwrap();
|
||||
assert!(ref_content.contains("[[Sprint Retro|my review]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rename_note_updates_title_frontmatter() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
create_test_file(vault, "note/old.md", "---\ntitle: Old Name\nIs A: Note\n---\n# Old Name\n");
|
||||
|
||||
let old_path = vault.join("note/old.md");
|
||||
let result = rename_note(
|
||||
vault.to_str().unwrap(),
|
||||
old_path.to_str().unwrap(),
|
||||
"New Name",
|
||||
).unwrap();
|
||||
|
||||
let content = fs::read_to_string(&result.new_path).unwrap();
|
||||
assert!(content.contains("title: New Name"));
|
||||
assert!(content.contains("# New Name"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,10 @@ function App() {
|
||||
setToastMessage(`Type "${name}" created`)
|
||||
}, [notes])
|
||||
|
||||
const handleRenameTab = useCallback((path: string, newTitle: string) => {
|
||||
notes.handleRenameNote(path, newTitle, vaultPath, vault.replaceEntry)
|
||||
}, [notes, vaultPath, vault])
|
||||
|
||||
useAppKeyboard({
|
||||
onQuickOpen: () => setShowQuickOpen(true),
|
||||
onCreateNote: handleCreateNoteImmediate,
|
||||
@@ -173,6 +177,7 @@ function App() {
|
||||
onRestoreNote={entryActions.handleRestoreNote}
|
||||
onArchiveNote={entryActions.handleArchiveNote}
|
||||
onUnarchiveNote={entryActions.handleUnarchiveNote}
|
||||
onRenameTab={handleRenameTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@ interface EditorProps {
|
||||
onRestoreNote?: (path: string) => void
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
onRenameTab?: (path: string, newTitle: string) => void
|
||||
}
|
||||
|
||||
// --- Custom Inline Content: WikiLink ---
|
||||
@@ -199,6 +200,7 @@ export const Editor = memo(function Editor({
|
||||
vaultPath,
|
||||
onTrashNote, onRestoreNote,
|
||||
onArchiveNote, onUnarchiveNote,
|
||||
onRenameTab,
|
||||
}: EditorProps) {
|
||||
const [diffMode, setDiffMode] = useState(false)
|
||||
const [diffContent, setDiffContent] = useState<string | null>(null)
|
||||
@@ -419,6 +421,7 @@ export const Editor = memo(function Editor({
|
||||
onCloseTab={onCloseTab}
|
||||
onCreateNote={onCreateNote}
|
||||
onReorderTabs={onReorderTabs}
|
||||
onRenameTab={onRenameTab}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { memo, useState, useRef, useCallback } from 'react'
|
||||
import { memo, useState, useRef, useCallback, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { X } from 'lucide-react'
|
||||
@@ -16,10 +16,73 @@ interface TabBarProps {
|
||||
onCloseTab: (path: string) => void
|
||||
onCreateNote?: () => void
|
||||
onReorderTabs?: (fromIndex: number, toIndex: number) => void
|
||||
onRenameTab?: (path: string, newTitle: string) => void
|
||||
}
|
||||
|
||||
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
|
||||
// --- Inline edit ---
|
||||
|
||||
/** Inline edit input shown when user double-clicks a tab title. */
|
||||
function InlineTabEdit({ initialValue, onSave, onCancel }: {
|
||||
initialValue: string
|
||||
onSave: (value: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
// Guard against double-fire: Enter calls handleSave, then React unmounts
|
||||
// the input (editingPath → null), which triggers blur → handleSave again.
|
||||
const committedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.select()
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (committedRef.current) return
|
||||
committedRef.current = true
|
||||
const trimmed = value.trim()
|
||||
if (trimmed && trimmed !== initialValue) {
|
||||
onSave(trimmed)
|
||||
} else {
|
||||
onCancel()
|
||||
}
|
||||
}, [value, initialValue, onSave, onCancel])
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSave()
|
||||
if (e.key === 'Escape') onCancel()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
onBlur={handleSave}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
draggable={false}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
style={{
|
||||
width: '100%',
|
||||
minWidth: 40,
|
||||
maxWidth: 150,
|
||||
background: 'var(--background)',
|
||||
border: '1px solid var(--ring)',
|
||||
borderRadius: 3,
|
||||
padding: '2px 6px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: 'var(--foreground)',
|
||||
outline: 'none',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Drag-and-drop helpers ---
|
||||
|
||||
function computeDropTarget(dragIdx: number | null, dropIdx: number | null): number | null {
|
||||
@@ -88,19 +151,23 @@ function DropIndicator({ side }: { side: 'left' | 'right' }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TabItem({ tab, isActive, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, dragProps }: {
|
||||
function TabItem({ tab, isActive, isEditing, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, onDoubleClick, onRenameSave, onRenameCancel, dragProps }: {
|
||||
tab: Tab
|
||||
isActive: boolean
|
||||
isEditing: boolean
|
||||
isDragging: boolean
|
||||
showDropBefore: boolean
|
||||
showDropAfter: boolean
|
||||
onSwitch: () => void
|
||||
onClose: () => void
|
||||
onDoubleClick: () => void
|
||||
onRenameSave: (newTitle: string) => void
|
||||
onRenameCancel: () => void
|
||||
dragProps: React.HTMLAttributes<HTMLDivElement>
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
draggable={!isEditing}
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
"group flex shrink-0 items-center gap-1.5 whitespace-nowrap max-w-[180px] transition-all relative",
|
||||
@@ -112,13 +179,19 @@ function TabItem({ tab, isActive, isDragging, showDropBefore, showDropAfter, onS
|
||||
borderBottom: isActive ? 'none' : '1px solid var(--sidebar-border)',
|
||||
padding: '0 12px', fontSize: 12,
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
cursor: isEditing ? 'default' : isDragging ? 'grabbing' : 'grab',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
} as React.CSSProperties}
|
||||
onClick={onSwitch}
|
||||
onClick={() => !isEditing && onSwitch()}
|
||||
>
|
||||
{showDropBefore && <DropIndicator side="left" />}
|
||||
<span className="truncate">{tab.entry.title}</span>
|
||||
{isEditing ? (
|
||||
<InlineTabEdit initialValue={tab.entry.title} onSave={onRenameSave} onCancel={onRenameCancel} />
|
||||
) : (
|
||||
<span className="truncate" onDoubleClick={(e) => { e.stopPropagation(); onDoubleClick() }}>
|
||||
{tab.entry.title}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
className={cn(
|
||||
"shrink-0 rounded-sm p-0 bg-transparent border-none text-muted-foreground cursor-pointer transition-opacity hover:bg-accent hover:text-foreground",
|
||||
@@ -160,9 +233,10 @@ function TabBarActions({ onCreateNote }: { onCreateNote?: () => void }) {
|
||||
// --- Main TabBar ---
|
||||
|
||||
export const TabBar = memo(function TabBar({
|
||||
tabs, activeTabPath, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs,
|
||||
tabs, activeTabPath, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs, onRenameTab,
|
||||
}: TabBarProps) {
|
||||
const { dragIndex, dropIndex, handleDragStart, handleDragEnd, handleDragOver, handleDrop, handleBarDragLeave } = useTabDrag(onReorderTabs)
|
||||
const [editingPath, setEditingPath] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -176,11 +250,15 @@ export const TabBar = memo(function TabBar({
|
||||
key={tab.entry.path}
|
||||
tab={tab}
|
||||
isActive={tab.entry.path === activeTabPath}
|
||||
isEditing={editingPath === tab.entry.path}
|
||||
isDragging={dragIndex !== null}
|
||||
showDropBefore={dropIndex === index}
|
||||
showDropAfter={dropIndex === index + 1 && index === tabs.length - 1}
|
||||
onSwitch={() => onSwitchTab(tab.entry.path)}
|
||||
onClose={() => onCloseTab(tab.entry.path)}
|
||||
onDoubleClick={() => onRenameTab && setEditingPath(tab.entry.path)}
|
||||
onRenameSave={(newTitle) => { setEditingPath(null); onRenameTab?.(tab.entry.path, newTitle) }}
|
||||
onRenameCancel={() => setEditingPath(null)}
|
||||
dragProps={{
|
||||
onDragStart: (e) => handleDragStart(e, index),
|
||||
onDragEnd: handleDragEnd,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, addMockEntry, updateMockContent } from '../mock-tauri'
|
||||
import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { useTabManagement } from './useTabManagement'
|
||||
@@ -14,6 +14,33 @@ interface NewEntryParams {
|
||||
status: string | null
|
||||
}
|
||||
|
||||
interface RenameResult {
|
||||
new_path: string
|
||||
updated_files: number
|
||||
}
|
||||
|
||||
async function performRename(
|
||||
path: string,
|
||||
newTitle: string,
|
||||
vaultPath: string,
|
||||
): Promise<RenameResult> {
|
||||
if (isTauri()) {
|
||||
return invoke<RenameResult>('rename_note', { vaultPath, oldPath: path, newTitle })
|
||||
}
|
||||
return mockInvoke<RenameResult>('rename_note', { vault_path: vaultPath, old_path: path, new_title: newTitle })
|
||||
}
|
||||
|
||||
function buildRenamedEntry(entry: VaultEntry, newTitle: string, newPath: string): VaultEntry {
|
||||
const slug = newTitle.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
return { ...entry, path: newPath, filename: `${slug}.md`, title: newTitle }
|
||||
}
|
||||
|
||||
async function loadNoteContent(path: string): Promise<string> {
|
||||
return isTauri()
|
||||
? invoke<string>('get_note_content', { path })
|
||||
: mockInvoke<string>('get_note_content', { path })
|
||||
}
|
||||
|
||||
function buildNewEntry({ path, slug, title, type, status }: NewEntryParams): VaultEntry {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
return {
|
||||
@@ -116,7 +143,7 @@ export function useNoteActions(
|
||||
setToastMessage: (msg: string | null) => void,
|
||||
) {
|
||||
const tabMgmt = useTabManagement()
|
||||
const { setTabs, handleSelectNote } = tabMgmt
|
||||
const { setTabs, handleSelectNote, activeTabPathRef, handleSwitchTab } = tabMgmt
|
||||
|
||||
const updateTabContent = useCallback((path: string, newContent: string) => {
|
||||
setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content: newContent } : t))
|
||||
@@ -153,6 +180,30 @@ export function useNoteActions(
|
||||
}
|
||||
}, [updateTabContent, setToastMessage])
|
||||
|
||||
const handleRenameNote = useCallback(async (
|
||||
path: string,
|
||||
newTitle: string,
|
||||
vaultPath: string,
|
||||
onEntryRenamed: (oldPath: string, newEntry: Partial<VaultEntry> & { path: string }, newContent: string) => void,
|
||||
) => {
|
||||
try {
|
||||
const result = await performRename(path, newTitle, vaultPath)
|
||||
const newContent = await loadNoteContent(result.new_path)
|
||||
const entry = entries.find((e) => e.path === path)
|
||||
const newEntry = buildRenamedEntry(entry ?? {} as VaultEntry, newTitle, result.new_path)
|
||||
|
||||
setTabs((prev) => prev.map((t) => t.entry.path === path ? { entry: newEntry, content: newContent } : t))
|
||||
if (activeTabPathRef.current === path) handleSwitchTab(result.new_path)
|
||||
onEntryRenamed(path, newEntry, newContent)
|
||||
|
||||
const n = result.updated_files
|
||||
setToastMessage(n > 0 ? `Renamed — updated ${n} wiki link${n > 1 ? 's' : ''}` : 'Renamed')
|
||||
} catch (err) {
|
||||
console.error('Failed to rename note:', err)
|
||||
setToastMessage('Failed to rename note')
|
||||
}
|
||||
}, [entries, setTabs, activeTabPathRef, handleSwitchTab, setToastMessage])
|
||||
|
||||
return {
|
||||
...tabMgmt,
|
||||
handleNavigateWikilink,
|
||||
@@ -161,5 +212,6 @@ export function useNoteActions(
|
||||
handleUpdateFrontmatter: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
||||
handleDeleteProperty: useCallback((path: string, key: string) => runFrontmatterOp('delete', path, key), [runFrontmatterOp]),
|
||||
handleAddProperty: useCallback((path: string, key: string, value: FrontmatterValue) => runFrontmatterOp('update', path, key, value), [runFrontmatterOp]),
|
||||
handleRenameNote,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,16 @@ export function useVaultLoader(vaultPath: string) {
|
||||
setEntries((prev) => prev.map((e) => e.path === path ? { ...e, ...patch } : e))
|
||||
}, [])
|
||||
|
||||
const replaceEntry = useCallback((oldPath: string, patch: Partial<VaultEntry> & { path: string }, newContent: string) => {
|
||||
setEntries((prev) => prev.map((e) => e.path === oldPath ? { ...e, ...patch } : e))
|
||||
setAllContent((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[oldPath]
|
||||
next[patch.path] = newContent
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const loadGitHistory = useCallback(async (path: string): Promise<GitCommit[]> => {
|
||||
try { return await tauriCall<GitCommit[]>('get_file_history', { vaultPath, path }, { path }) }
|
||||
catch (err) { console.warn('Failed to load git history:', err); return [] }
|
||||
@@ -93,6 +103,7 @@ export function useVaultLoader(vaultPath: string) {
|
||||
modifiedFiles,
|
||||
addEntry,
|
||||
updateEntry,
|
||||
replaceEntry,
|
||||
updateContent,
|
||||
loadModifiedFiles,
|
||||
loadGitHistory,
|
||||
|
||||
@@ -1679,6 +1679,42 @@ const mockHandlers: Record<string, (args: any) => any> = {
|
||||
const timestamp = Date.now()
|
||||
return `${vault}/attachments/${timestamp}-${args.filename}`
|
||||
},
|
||||
rename_note: (args: { vault_path: string; old_path: string; new_title: string }) => {
|
||||
const oldContent = MOCK_CONTENT[args.old_path] ?? ''
|
||||
const slug = args.new_title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
const parentDir = args.old_path.replace(/\/[^/]+$/, '')
|
||||
const newPath = `${parentDir}/${slug}.md`
|
||||
|
||||
// Update H1 heading in content
|
||||
const newContent = oldContent.replace(/^# .+$/m, `# ${args.new_title}`)
|
||||
|
||||
// Move content to new path
|
||||
delete MOCK_CONTENT[args.old_path]
|
||||
MOCK_CONTENT[newPath] = newContent
|
||||
|
||||
// Update wikilinks in other notes
|
||||
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
|
||||
const oldTitle = oldEntry?.title ?? ''
|
||||
let updatedFiles = 0
|
||||
if (oldTitle) {
|
||||
const pattern = new RegExp(`\\[\\[${oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\|[^\\]]*?)?\\]\\]`, 'g')
|
||||
for (const [path, content] of Object.entries(MOCK_CONTENT)) {
|
||||
if (path === newPath) continue
|
||||
const replaced = content.replace(pattern, (_m: string, pipe: string | undefined) =>
|
||||
pipe ? `[[${args.new_title}${pipe}]]` : `[[${args.new_title}]]`
|
||||
)
|
||||
if (replaced !== content) {
|
||||
MOCK_CONTENT[path] = replaced
|
||||
updatedFiles++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__mockContent = MOCK_CONTENT
|
||||
}
|
||||
return { new_path: newPath, updated_files: updatedFiles }
|
||||
},
|
||||
}
|
||||
|
||||
export function isTauri(): boolean {
|
||||
|
||||
582
ui-design.pen
582
ui-design.pen
@@ -9602,6 +9602,588 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rnt0",
|
||||
"name": "Rename Tab — Normal tab state",
|
||||
"width": 800,
|
||||
"height": 120,
|
||||
"layout": "vertical",
|
||||
"fill": "$--sidebar",
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rnt1",
|
||||
"name": "frameLabel",
|
||||
"content": "Normal state: tabs show note title. Double-click to rename.",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "normal",
|
||||
"fill": "$--muted-foreground",
|
||||
"padding": [
|
||||
8,
|
||||
12
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rnt2",
|
||||
"name": "Tab Bar Normal",
|
||||
"width": "fill_container",
|
||||
"height": 45,
|
||||
"fill": "$--sidebar",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--sidebar-border"
|
||||
},
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rnt3",
|
||||
"name": "Tab Active",
|
||||
"height": "fill_container",
|
||||
"fill": "$--background",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"right": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"gap": 6,
|
||||
"padding": [
|
||||
0,
|
||||
14
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rnt4",
|
||||
"fill": "$--foreground",
|
||||
"content": "Weekly Review",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "rnt5",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "x",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rnt6",
|
||||
"name": "Tab Inactive",
|
||||
"height": "fill_container",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"right": 1,
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--sidebar-border"
|
||||
},
|
||||
"gap": 6,
|
||||
"padding": [
|
||||
0,
|
||||
14
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rnt7",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Laputa App",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "rnt8",
|
||||
"opacity": 0,
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "x",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rnt9",
|
||||
"name": "tabSpacer",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rnta",
|
||||
"name": "tabControls",
|
||||
"height": "fill_container",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1,
|
||||
"left": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
0,
|
||||
12
|
||||
],
|
||||
"justifyContent": "space_around",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "rntb",
|
||||
"width": 16,
|
||||
"height": 16,
|
||||
"iconFontName": "plus",
|
||||
"iconFontFamily": "phosphor",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rntc",
|
||||
"name": "interactionNote",
|
||||
"content": "Double-click tab title → enters edit mode",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "normal",
|
||||
"fill": "$--muted-foreground",
|
||||
"padding": [
|
||||
4,
|
||||
12
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rne0",
|
||||
"name": "Rename Tab — Double-clicked (editing mode)",
|
||||
"width": 800,
|
||||
"height": 120,
|
||||
"layout": "vertical",
|
||||
"fill": "$--sidebar",
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rne1",
|
||||
"name": "frameLabel",
|
||||
"content": "Editing mode: tab title replaced with inline input field, text selected.",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "normal",
|
||||
"fill": "$--muted-foreground",
|
||||
"padding": [
|
||||
8,
|
||||
12
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rne2",
|
||||
"name": "Tab Bar Editing",
|
||||
"width": "fill_container",
|
||||
"height": 45,
|
||||
"fill": "$--sidebar",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--sidebar-border"
|
||||
},
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rne3",
|
||||
"name": "Tab Active (Editing)",
|
||||
"height": "fill_container",
|
||||
"fill": "$--background",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"right": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"gap": 6,
|
||||
"padding": [
|
||||
0,
|
||||
14
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rne4",
|
||||
"name": "Inline Input",
|
||||
"fill": "$--background",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "$--ring"
|
||||
},
|
||||
"cornerRadius": 3,
|
||||
"padding": [
|
||||
2,
|
||||
6
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rne5",
|
||||
"fill": "$--foreground",
|
||||
"content": "Weekly Review",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500",
|
||||
"highlight": "$--primary",
|
||||
"highlightOpacity": 0.2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "rne6",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "x",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rne7",
|
||||
"name": "Tab Inactive",
|
||||
"height": "fill_container",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"right": 1,
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--sidebar-border"
|
||||
},
|
||||
"gap": 6,
|
||||
"padding": [
|
||||
0,
|
||||
14
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rne8",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Laputa App",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "rne9",
|
||||
"opacity": 0,
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "x",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rnea",
|
||||
"name": "tabSpacer",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rneb",
|
||||
"name": "tabControls",
|
||||
"height": "fill_container",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1,
|
||||
"left": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
0,
|
||||
12
|
||||
],
|
||||
"justifyContent": "space_around",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "rnec",
|
||||
"width": 16,
|
||||
"height": 16,
|
||||
"iconFontName": "plus",
|
||||
"iconFontFamily": "phosphor",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rned",
|
||||
"name": "interactionNote",
|
||||
"content": "Enter → save & rename file + update wiki links | Escape → cancel | Blur → save",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "normal",
|
||||
"fill": "$--muted-foreground",
|
||||
"padding": [
|
||||
4,
|
||||
12
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rns0",
|
||||
"name": "Rename Tab — Saved (updated name)",
|
||||
"width": 800,
|
||||
"height": 120,
|
||||
"layout": "vertical",
|
||||
"fill": "$--sidebar",
|
||||
"theme": {
|
||||
"Mode": "Light"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rns1",
|
||||
"name": "frameLabel",
|
||||
"content": "After save: tab shows updated title. File renamed, wiki links updated across vault.",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 11,
|
||||
"fontWeight": "normal",
|
||||
"fill": "$--muted-foreground",
|
||||
"padding": [
|
||||
8,
|
||||
12
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rns2",
|
||||
"name": "Tab Bar Saved",
|
||||
"width": "fill_container",
|
||||
"height": 45,
|
||||
"fill": "$--sidebar",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--sidebar-border"
|
||||
},
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rns3",
|
||||
"name": "Tab Active (Renamed)",
|
||||
"height": "fill_container",
|
||||
"fill": "$--background",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"right": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"gap": 6,
|
||||
"padding": [
|
||||
0,
|
||||
14
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rns4",
|
||||
"fill": "$--foreground",
|
||||
"content": "Sprint Retrospective",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "rns5",
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "x",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rns6",
|
||||
"name": "Tab Inactive",
|
||||
"height": "fill_container",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"right": 1,
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--sidebar-border"
|
||||
},
|
||||
"gap": 6,
|
||||
"padding": [
|
||||
0,
|
||||
14
|
||||
],
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rns7",
|
||||
"fill": "$--muted-foreground",
|
||||
"content": "Laputa App",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "rns8",
|
||||
"opacity": 0,
|
||||
"width": 14,
|
||||
"height": 14,
|
||||
"iconFontName": "x",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rns9",
|
||||
"name": "tabSpacer",
|
||||
"width": "fill_container",
|
||||
"height": "fill_container",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "rnsa",
|
||||
"name": "tabControls",
|
||||
"height": "fill_container",
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": {
|
||||
"bottom": 1,
|
||||
"left": 1
|
||||
},
|
||||
"fill": "$--border"
|
||||
},
|
||||
"gap": 12,
|
||||
"padding": [
|
||||
0,
|
||||
12
|
||||
],
|
||||
"justifyContent": "space_around",
|
||||
"alignItems": "center",
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "rnsb",
|
||||
"width": 16,
|
||||
"height": 16,
|
||||
"iconFontName": "plus",
|
||||
"iconFontFamily": "phosphor",
|
||||
"fill": "$--muted-foreground"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "rnsc",
|
||||
"name": "interactionNote",
|
||||
"content": "File: weekly-review.md → sprint-retrospective.md | [[Weekly Review]] → [[Sprint Retrospective]] in all notes",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "normal",
|
||||
"fill": "$--muted-foreground",
|
||||
"padding": [
|
||||
4,
|
||||
12
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"themes": {
|
||||
|
||||
Reference in New Issue
Block a user