From b9521f128d9d3666e44b721737cd27f8881133bb Mon Sep 17 00:00:00 2001 From: w0nk1 Date: Mon, 9 Feb 2026 13:57:41 +0100 Subject: [PATCH 1/5] feat: add step editor window with delete, reorder, and improved export - Add separate editor window (Vite multi-page) with timeline layout - Step delete (editor + panel) and drag-and-drop reorder (panel, @dnd-kit) - Replace standalone Discard button with New dropdown menu (New Recording / Discard All) - Redesign HTML/PDF export with modern timeline layout matching editor - Add pre-write validation (dir writable, file overwritable, disk space) - Improve export error messages with user-friendly descriptions - Close panel on editor open and after successful export - Fix editor scroll bleed behind macOS title bar (container-level scroll) Co-Authored-By: Claude Opus 4.6 --- editor.html | 14 + package-lock.json | 66 +++- package.json | 3 + src-tauri/capabilities/default.json | 4 + src-tauri/capabilities/editor.json | 13 + .../autogenerated/delete_step.toml | 11 + .../autogenerated/open_editor_window.toml | 11 + .../autogenerated/reorder_steps.toml | 11 + .../autogenerated/update_step_note.toml | 11 + src-tauri/src/export/html.rs | 97 +++--- src-tauri/src/export/markdown.rs | 2 +- src-tauri/src/export/mod.rs | 120 +++++++- src-tauri/src/export/pdf.rs | 2 +- src-tauri/src/lib.rs | 99 +++++- src-tauri/src/recorder/session.rs | 46 +++ src-tauri/tauri.conf.json | 2 +- src/App.css | 93 ++++++ src/components/EditorStepCard.test.tsx | 134 ++++++++ src/components/EditorStepCard.tsx | 133 ++++++++ src/components/EditorWindow.test.tsx | 227 ++++++++++++++ src/components/EditorWindow.tsx | 90 ++++++ src/components/RecorderPanel.test.tsx | 285 ++++++++++++++++++ src/components/RecorderPanel.tsx | 120 +++++++- src/components/StepItem.tsx | 34 ++- src/editor.css | 263 ++++++++++++++++ src/editor.tsx | 14 + src/test/setup.ts | 6 + vite.config.ts | 10 + 28 files changed, 1855 insertions(+), 66 deletions(-) create mode 100644 editor.html create mode 100644 src-tauri/capabilities/editor.json create mode 100644 src-tauri/permissions/autogenerated/delete_step.toml create mode 100644 src-tauri/permissions/autogenerated/open_editor_window.toml create mode 100644 src-tauri/permissions/autogenerated/reorder_steps.toml create mode 100644 src-tauri/permissions/autogenerated/update_step_note.toml create mode 100644 src/components/EditorStepCard.test.tsx create mode 100644 src/components/EditorStepCard.tsx create mode 100644 src/components/EditorWindow.test.tsx create mode 100644 src/components/EditorWindow.tsx create mode 100644 src/editor.css create mode 100644 src/editor.tsx diff --git a/editor.html b/editor.html new file mode 100644 index 0000000..93368a9 --- /dev/null +++ b/editor.html @@ -0,0 +1,14 @@ + + + + + + + Step Editor + + + +
+ + + diff --git a/package-lock.json b/package-lock.json index 158e897..7a96d70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,17 @@ { "name": "stepcast", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "stepcast", - "version": "0.2.1", + "version": "0.2.2", "dependencies": { "@aptabase/tauri": "^0.4.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", @@ -521,6 +524,59 @@ "node": ">=20.19.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -3042,6 +3098,12 @@ "node": ">=20" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.8.3", "dev": true, diff --git a/package.json b/package.json index 730529c..5aff40f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ }, "dependencies": { "@aptabase/tauri": "^0.4.1", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-opener": "^2", diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 099191c..1e3a872 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -14,6 +14,10 @@ "allow-resume-recording", "allow-stop-recording", "allow-get-steps", + "allow-update-step-note", + "allow-delete-step", + "allow-reorder-steps", + "allow-open-editor-window", "allow-export-guide", "allow-discard-recording", "allow-get-startup-state", diff --git a/src-tauri/capabilities/editor.json b/src-tauri/capabilities/editor.json new file mode 100644 index 0000000..5225b17 --- /dev/null +++ b/src-tauri/capabilities/editor.json @@ -0,0 +1,13 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "editor", + "description": "Capability for the step editor window", + "windows": ["step-editor"], + "permissions": [ + "core:default", + "allow-get-steps", + "allow-update-step-note", + "allow-delete-step", + "allow-open-editor-window" + ] +} diff --git a/src-tauri/permissions/autogenerated/delete_step.toml b/src-tauri/permissions/autogenerated/delete_step.toml new file mode 100644 index 0000000..05a8491 --- /dev/null +++ b/src-tauri/permissions/autogenerated/delete_step.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-delete-step" +description = "Enables the delete_step command without any pre-configured scope." +commands.allow = ["delete_step"] + +[[permission]] +identifier = "deny-delete-step" +description = "Denies the delete_step command without any pre-configured scope." +commands.deny = ["delete_step"] diff --git a/src-tauri/permissions/autogenerated/open_editor_window.toml b/src-tauri/permissions/autogenerated/open_editor_window.toml new file mode 100644 index 0000000..52ce670 --- /dev/null +++ b/src-tauri/permissions/autogenerated/open_editor_window.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-open-editor-window" +description = "Enables the open_editor_window command without any pre-configured scope." +commands.allow = ["open_editor_window"] + +[[permission]] +identifier = "deny-open-editor-window" +description = "Denies the open_editor_window command without any pre-configured scope." +commands.deny = ["open_editor_window"] diff --git a/src-tauri/permissions/autogenerated/reorder_steps.toml b/src-tauri/permissions/autogenerated/reorder_steps.toml new file mode 100644 index 0000000..855cf36 --- /dev/null +++ b/src-tauri/permissions/autogenerated/reorder_steps.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-reorder-steps" +description = "Enables the reorder_steps command without any pre-configured scope." +commands.allow = ["reorder_steps"] + +[[permission]] +identifier = "deny-reorder-steps" +description = "Denies the reorder_steps command without any pre-configured scope." +commands.deny = ["reorder_steps"] diff --git a/src-tauri/permissions/autogenerated/update_step_note.toml b/src-tauri/permissions/autogenerated/update_step_note.toml new file mode 100644 index 0000000..e3c424e --- /dev/null +++ b/src-tauri/permissions/autogenerated/update_step_note.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-update-step-note" +description = "Enables the update_step_note command without any pre-configured scope." +commands.allow = ["update_step_note"] + +[[permission]] +identifier = "deny-update-step-note" +description = "Denies the update_step_note command without any pre-configured scope." +commands.deny = ["update_step_note"] diff --git a/src-tauri/src/export/html.rs b/src-tauri/src/export/html.rs index 130642c..cd855b6 100644 --- a/src-tauri/src/export/html.rs +++ b/src-tauri/src/export/html.rs @@ -26,8 +26,13 @@ pub fn generate_for(title: &str, steps: &[Step], target: ImageTarget) -> String -

{title_esc} — {count} step{plural}

+
+

{title_esc}

+

{count} step{plural}

+
{steps_html} +
+
"#, title_esc = html_escape(title), @@ -67,19 +72,21 @@ fn render_step(num: usize, step: &Step, target: ImageTarget) -> String { format!( r#" -
-
- Step {num} - {desc} -
-
-
- {image_html} - {click_marker} +
+
{num}
+
+
+ {desc}
-
- {note_html} -
"# +
+
+ {image_html} + {click_marker} +
+
+ {note_html} + + "# ) } @@ -88,38 +95,44 @@ fn escape_text(s: &str) -> String { html_escape(s).replace('\'', "'") } -const CSS: &str = r#"* { box-sizing: border-box; } -body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 800px; margin: 0 auto; padding: 40px 20px; color: #1d1d1f; background: #fff; line-height: 1.5; } -h1 { margin-bottom: 8px; } -.meta { color: #86868b; font-size: 14px; margin-bottom: 32px; } -.step { margin-bottom: 32px; border: 1px solid #e8e8ed; border-radius: 12px; overflow: hidden; } -.step-header { padding: 12px 16px; background: #f5f5f7; border-bottom: 1px solid #e8e8ed; display: flex; gap: 12px; align-items: center; } -.step-number { font-weight: 600; font-size: 12px; text-transform: uppercase; color: #86868b; white-space: nowrap; } -.step-app { color: #1d1d1f; } -.step-image { background: #f5f5f7; display: flex; align-items: center; justify-content: center; padding: 16px; } -.step-image img { display: block; max-width: 100%; height: auto; } -.image-wrapper { position: relative; display: inline-block; max-width: 100%; } -.step-note { margin: 0; padding: 10px 16px; font-size: 14px; color: #1d1d1f; background: #fef9e7; border-top: 1px solid #e8e8ed; } +const CSS: &str = r#"* { box-sizing: border-box; margin: 0; padding: 0; } +body { font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif; background: #f5f5f7; color: #1d1d1f; line-height: 1.5; -webkit-font-smoothing: antialiased; -webkit-print-color-adjust: exact; print-color-adjust: exact; } +.container { max-width: 860px; margin: 0 auto; padding: 40px 32px 64px; } +h1 { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 4px; } +.subtitle { font-size: 14px; color: #86868b; margin-bottom: 32px; } +.timeline { display: flex; flex-direction: column; position: relative; } +.timeline::before { content: ''; position: absolute; left: 15px; top: 16px; bottom: 16px; width: 2px; background: #d1d1d6; border-radius: 1px; } +.timeline-item { display: grid; grid-template-columns: 32px 1fr; gap: 16px; padding-bottom: 24px; position: relative; } +.timeline-item:last-child { padding-bottom: 0; } +.timeline-badge { width: 32px; height: 32px; border-radius: 50%; background: #7c5cfc; color: #fff; font-size: 13px; font-weight: 700; display: flex; align-items: center; justify-content: center; position: relative; z-index: 1; box-shadow: 0 0 0 4px #f5f5f7; flex-shrink: 0; } +.step { border: 1px solid #d1d1d6; border-radius: 14px; overflow: hidden; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.03); } +.step-header { display: flex; align-items: center; gap: 12px; padding: 14px 20px; } +.step-desc { font-size: 14px; font-weight: 600; color: #1d1d1f; } +.step-image { padding: 0 20px 16px; display: flex; align-items: center; justify-content: center; } +.image-wrapper { position: relative; display: inline-block; max-width: 100%; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 2px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.08); border: 1px solid #d1d1d6; } +.image-wrapper img { display: block; max-width: 100%; height: auto; } +.step-note { margin: 0; padding: 12px 20px 16px; font-size: 13px; color: #1d1d1f; background: rgba(124,92,252,0.05); border-top: none; } .click-marker { position: absolute; width: 24px; height: 24px; border-radius: 50%; background: transparent; border: 2.5px solid #ff3b30; box-shadow: 0 0 0 1.5px rgba(255,255,255,0.9), 0 2px 6px rgba(0,0,0,0.25); transform: translate(-50%, -50%); pointer-events: none; } .click-marker.double-click { width: 18px; height: 18px; border-width: 2px; } .click-marker.double-click::after { content: ''; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 30px; height: 30px; border-radius: 50%; border: 2px solid #ff3b30; box-shadow: 0 0 0 1.5px rgba(255,255,255,0.9); pointer-events: none; } .click-marker.right-click { border-style: dashed; } @media print { - .step { break-inside: avoid; } - body { background: #fff !important; color: #1d1d1f !important; } - .step { border-color: #e8e8ed !important; } - .step-header { background: #f5f5f7 !important; border-color: #e8e8ed !important; } - .step-app { color: #1d1d1f !important; } - .step-image { background: #f5f5f7 !important; } - .step-note { background: #fef9e7 !important; color: #1d1d1f !important; border-color: #e8e8ed !important; } + body { background: #fff !important; } + .container { padding: 20px !important; } + .timeline::before { background: #d1d1d6 !important; } + .timeline-badge { box-shadow: 0 0 0 4px #fff !important; } + .timeline-item { break-inside: avoid; } + .step { box-shadow: none !important; border-color: #d1d1d6 !important; } } @media (prefers-color-scheme: dark) { - body { background: #1d1d1f; color: #f5f5f7; } - .step { border-color: #38383d; } - .step-header { background: #2c2c2e; border-color: #38383d; } - .step-app { color: #f5f5f7; } - .step-image { background: #2c2c2e; } - .step-note { background: #3a3520; color: #f5f5f7; border-color: #38383d; } + body { background: #1c1c1e; color: #f5f5f7; } + .subtitle { color: #98989d; } + .timeline::before { background: #38383a; } + .timeline-badge { box-shadow: 0 0 0 4px #1c1c1e; } + .step { background: #2c2c2e; border-color: #38383a; box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), 0 1px 3px rgba(0,0,0,0.2), 0 4px 12px rgba(0,0,0,0.15); } + .step-desc { color: #f5f5f7; } + .image-wrapper { border-color: #38383a; } + .step-note { color: #f5f5f7; background: rgba(167,139,250,0.08); } }"#; #[cfg(test)] @@ -148,7 +161,8 @@ mod tests { fn generate_contains_title() { let html = generate("Test Guide", &[sample_step()]); assert!(html.contains("Test Guide")); - assert!(html.contains("

Test Guide — 1 step

")); + assert!(html.contains("

Test Guide

")); + assert!(html.contains("1 step")); } #[test] @@ -164,9 +178,10 @@ mod tests { } #[test] - fn generate_contains_step_article() { + fn generate_contains_timeline_structure() { let html = generate("G", &[sample_step()]); - assert!(html.contains("Step 1")); + assert!(html.contains("timeline-badge")); + assert!(html.contains("timeline-item")); assert!(html.contains("Clicked in Finder")); } diff --git a/src-tauri/src/export/markdown.rs b/src-tauri/src/export/markdown.rs index 8e9f087..e0b4ab9 100644 --- a/src-tauri/src/export/markdown.rs +++ b/src-tauri/src/export/markdown.rs @@ -109,7 +109,7 @@ pub fn write(title: &str, steps: &[Step], output_path: &str) -> Result<(), Strin }; fs::write(output_path, buf) - .map_err(|e| format!("Failed to write zip file: {e}"))?; + .map_err(|e| super::friendly_write_error(&e, output_path))?; Ok(()) } diff --git a/src-tauri/src/export/mod.rs b/src-tauri/src/export/mod.rs index f3d060a..c0868da 100644 --- a/src-tauri/src/export/mod.rs +++ b/src-tauri/src/export/mod.rs @@ -4,6 +4,7 @@ pub mod markdown; pub mod pdf; use crate::recorder::types::Step; +use std::path::Path; #[derive(Debug, Clone, Copy)] pub enum ExportFormat { @@ -23,13 +24,110 @@ impl ExportFormat { } } +/// Turn an IO error into a user-friendly message. +fn friendly_write_error(e: &std::io::Error, path: &str) -> String { + match e.kind() { + std::io::ErrorKind::PermissionDenied => { + format!("Cannot save to \"{path}\" — permission denied. Is the file open in another app or the folder read-only?") + } + std::io::ErrorKind::NotFound => { + format!("The folder for \"{path}\" does not exist.") + } + _ if e.raw_os_error() == Some(28) /* ENOSPC */ => { + "Not enough disk space to save the file.".to_string() + } + _ => format!("Could not save file: {e}"), + } +} + +/// Pre-validate that we can write to `output_path` before doing expensive work. +/// +/// Checks: parent dir writable (tempfile probe), existing file writable, +/// sufficient disk space. Total cost: ~3 syscalls, <1ms. +fn validate_write_access(output_path: &str, estimated_bytes: u64) -> Result<(), String> { + let path = Path::new(output_path); + + let parent = path.parent().ok_or_else(|| { + format!("Invalid output path: \"{output_path}\"") + })?; + + if !parent.exists() { + return Err(format!( + "The folder \"{}\" does not exist.", + parent.display() + )); + } + + // Probe writability: create a temp file in the same directory + let probe_path = parent.join(format!(".stepcast_probe_{}", std::process::id())); + match std::fs::File::create(&probe_path) { + Ok(_) => { let _ = std::fs::remove_file(&probe_path); } + Err(e) => { + let _ = std::fs::remove_file(&probe_path); + return Err(match e.kind() { + std::io::ErrorKind::PermissionDenied => format!( + "Cannot write to folder \"{}\" — permission denied.", + parent.display() + ), + _ => format!("Cannot write to folder \"{}\": {e}", parent.display()), + }); + } + } + + // If target file exists, verify it is writable (opens without truncating) + if path.exists() { + if let Err(e) = std::fs::OpenOptions::new().write(true).open(path) { + return Err(match e.kind() { + std::io::ErrorKind::PermissionDenied => format!( + "Cannot overwrite \"{}\" — the file is read-only or locked.", + path.display() + ), + _ => format!("Cannot write to \"{}\": {e}", path.display()), + }); + } + } + + // Check available disk space via statvfs + if let Some(dir_str) = parent.to_str() { + if let Ok(avail) = available_disk_space(dir_str) { + const MIN_BUFFER: u64 = 10 * 1024 * 1024; // 10 MB safety margin + let needed = estimated_bytes + MIN_BUFFER; + if avail < needed { + let need_mb = needed / (1024 * 1024); + let have_mb = avail / (1024 * 1024); + return Err(format!( + "Not enough disk space. Need ~{need_mb} MB, but only {have_mb} MB available." + )); + } + } + } + + Ok(()) +} + +/// Returns available disk space in bytes for the filesystem containing `path`. +fn available_disk_space(path: &str) -> std::io::Result { + let c_path = std::ffi::CString::new(path) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?; + let mut stat: libc::statvfs = unsafe { std::mem::zeroed() }; + let ret = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) }; + if ret != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(stat.f_bavail as u64 * stat.f_frsize) +} + /// Unified export: writes the given steps to output_path in the requested format. pub fn export(title: &str, steps: &[Step], format: ExportFormat, output_path: &str, app: &tauri::AppHandle) -> Result<(), String> { + // Pre-validate before expensive work (~500KB per step estimate) + let estimated_bytes = (steps.len() as u64) * 500_000 + 100_000; + validate_write_access(output_path, estimated_bytes)?; + match format { ExportFormat::Html => { let content = html::generate(title, steps); std::fs::write(output_path, content) - .map_err(|e| format!("Failed to write HTML: {e}")) + .map_err(|e| friendly_write_error(&e, output_path)) } ExportFormat::Markdown => { markdown::write(title, steps, output_path) @@ -55,4 +153,24 @@ mod tests { fn format_from_str_invalid() { assert!(ExportFormat::from_str("docx").is_err()); } + + #[test] + fn validate_write_access_writable_dir() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("test.html").to_str().unwrap().to_string(); + assert!(validate_write_access(&path, 1000).is_ok()); + } + + #[test] + fn validate_write_access_nonexistent_parent() { + let result = validate_write_access("/nonexistent/dir/file.html", 1000); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("does not exist")); + } + + #[test] + fn available_disk_space_current_dir() { + let space = available_disk_space(".").unwrap(); + assert!(space > 0); + } } diff --git a/src-tauri/src/export/pdf.rs b/src-tauri/src/export/pdf.rs index 94fbb2a..73853bc 100644 --- a/src-tauri/src/export/pdf.rs +++ b/src-tauri/src/export/pdf.rs @@ -131,7 +131,7 @@ fn render_pdf_on_main_thread(html: &str, output_path: &str, tx: mpsc::Sender) -> Result, Str .session .lock() .map_err(|_| "session lock poisoned")?; - Ok(session_lock + let steps = session_lock .as_ref() .map(|s| s.get_steps().to_vec()) - .unwrap_or_default()) + .unwrap_or_default(); + Ok(steps) } #[tauri::command] @@ -704,6 +705,9 @@ fn discard_recording( ps.reset(); } + // Notify editor window (if open) that steps were discarded + let _ = app.emit("steps-discarded", ()); + // Show panel and reset icon on main thread after discard let app_clone = app.clone(); let _ = app.run_on_main_thread(move || { @@ -733,6 +737,91 @@ fn discard_recording( Ok(()) } +#[tauri::command] +fn update_step_note( + app: tauri::AppHandle, + state: tauri::State<'_, RecorderAppState>, + step_id: String, + note: Option, +) -> Result<(), String> { + let mut session_lock = state + .session + .lock() + .map_err(|_| "session lock poisoned")?; + let session = session_lock + .as_mut() + .ok_or("no active session")?; + let updated = session + .update_step_note(&step_id, note) + .ok_or("step not found")? + .clone(); + let _ = app.emit("step-updated", &updated); + Ok(()) +} + +#[tauri::command] +fn delete_step( + app: tauri::AppHandle, + state: tauri::State<'_, RecorderAppState>, + step_id: String, +) -> Result<(), String> { + let mut session_lock = state + .session + .lock() + .map_err(|_| "session lock poisoned")?; + let session = session_lock + .as_mut() + .ok_or("no active session")?; + if !session.delete_step(&step_id) { + return Err("step not found".into()); + } + let _ = app.emit("step-deleted", &step_id); + Ok(()) +} + +#[tauri::command] +fn reorder_steps( + app: tauri::AppHandle, + state: tauri::State<'_, RecorderAppState>, + step_ids: Vec, +) -> Result, String> { + let mut session_lock = state + .session + .lock() + .map_err(|_| "session lock poisoned")?; + let session = session_lock + .as_mut() + .ok_or("no active session")?; + session.reorder_steps(&step_ids); + let steps = session.get_steps().to_vec(); + let _ = app.emit("steps-reordered", &steps); + Ok(steps) +} + +#[tauri::command] +fn open_editor_window(app: tauri::AppHandle) -> Result<(), String> { + // Hide the tray panel so it doesn't overlap the editor + if let Some(panel_window) = app.get_webview_window(panel::panel_label()) { + let _ = panel_window.hide(); + } + + // If editor already exists, focus it + if let Some(window) = app.get_webview_window("step-editor") { + let _ = window.set_focus(); + return Ok(()); + } + + WebviewWindowBuilder::new(&app, "step-editor", WebviewUrl::App("/editor.html".into())) + .title("Step Editor") + .inner_size(900.0, 700.0) + .resizable(true) + .decorations(true) + .build() + .map_err(|e| format!("Failed to create editor window: {e}"))?; + + Ok(()) +} + #[tauri::command] async fn export_guide( app: tauri::AppHandle, @@ -845,6 +934,10 @@ pub fn run() { resume_recording, stop_recording, get_steps, + update_step_note, + delete_step, + reorder_steps, + open_editor_window, export_guide, discard_recording, get_startup_state, diff --git a/src-tauri/src/recorder/session.rs b/src-tauri/src/recorder/session.rs index a21e17f..88771af 100644 --- a/src-tauri/src/recorder/session.rs +++ b/src-tauri/src/recorder/session.rs @@ -85,6 +85,32 @@ impl Session { self.steps.last_mut() } + /// Update a step's note by ID. Returns the updated step or None if not found. + pub fn update_step_note(&mut self, step_id: &str, note: Option) -> Option<&Step> { + let step = self.steps.iter_mut().find(|s| s.id == step_id)?; + step.note = note; + Some(step) + } + + /// Remove a step by ID. Returns true if found and removed. + pub fn delete_step(&mut self, step_id: &str) -> bool { + let before = self.steps.len(); + self.steps.retain(|s| s.id != step_id); + self.steps.len() < before + } + + /// Reorder steps to match the given ID sequence. + /// IDs not in the list are dropped; unknown IDs are ignored. + pub fn reorder_steps(&mut self, step_ids: &[String]) { + let mut reordered = Vec::with_capacity(step_ids.len()); + for id in step_ids { + if let Some(pos) = self.steps.iter().position(|s| s.id == *id) { + reordered.push(self.steps.swap_remove(pos)); + } + } + self.steps = reordered; + } + pub fn next_step_id(&self) -> String { format!("step-{:03}", self.steps.len() + 1) } @@ -133,6 +159,26 @@ mod tests { std::fs::remove_dir_all(&session.temp_dir).ok(); } + #[test] + fn update_step_note_sets_note() { + let mut session = Session::new().expect("create session"); + session.add_step(Step::sample()); + + let updated = session.update_step_note("step-1", Some("Hello".into())); + assert!(updated.is_some()); + assert_eq!(updated.unwrap().note, Some("Hello".into())); + + // Clear note + let updated = session.update_step_note("step-1", None); + assert!(updated.is_some()); + assert_eq!(updated.unwrap().note, None); + + // Nonexistent step + assert!(session.update_step_note("nonexistent", Some("x".into())).is_none()); + + std::fs::remove_dir_all(&session.temp_dir).ok(); + } + #[test] fn write_diagnostics_creates_json() { let mut session = Session::new().expect("create session"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index acd1960..6702c1a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -38,7 +38,7 @@ "style-src": "'self' 'unsafe-inline' http://localhost:1420", "script-src": "'self' 'unsafe-eval' http://localhost:1420" }, - "capabilities": ["default"], + "capabilities": ["default", "editor"], "assetProtocol": { "enable": true, "scope": ["$HOME/Library/Caches/**", "$TEMP/**"] diff --git a/src/App.css b/src/App.css index baa6d8f..f7105bc 100644 --- a/src/App.css +++ b/src/App.css @@ -487,6 +487,30 @@ button:focus, button:focus-visible { border: 1px solid var(--border); } +.drag-handle { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + flex-shrink: 0; + cursor: grab; + color: var(--text-secondary); + opacity: 0.5; + border: none; + background: none; + padding: 0; + -webkit-app-region: no-drag; + touch-action: none; +} + +.drag-handle:hover { + opacity: 1; +} + +.drag-handle:active { + cursor: grabbing; +} + .step-thumb { width: 40px; height: 30px; @@ -604,6 +628,75 @@ button:focus, button:focus-visible { flex: 1; } +/* Dropdown menu (New button) */ +.action-dropdown { + position: relative; + flex: 1; +} + +.action-dropdown .button { + width: 100%; +} + +.action-dropdown-backdrop { + position: fixed; + inset: 0; + z-index: 19; +} + +.action-dropdown-menu { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + right: 0; + z-index: 20; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 1px 4px rgba(0, 0, 0, 0.08); + padding: 4px; + animation: overlay-fade-in 0.1s ease; +} + +.action-dropdown-menu button { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 12px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-primary); + font-size: 13px; + font-family: inherit; + cursor: pointer; + -webkit-app-region: no-drag; +} + +.action-dropdown-menu button:hover { + background: var(--bg-secondary); +} + +.action-dropdown-menu button.danger { + color: var(--danger); +} + +.action-dropdown-menu button.danger:hover { + background: rgba(255, 59, 48, 0.1); +} + +.action-dropdown-menu button svg { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.action-dropdown-menu button:disabled { + opacity: 0.4; + cursor: default; +} + /* Export Sheet Overlay */ .export-overlay { position: absolute; diff --git a/src/components/EditorStepCard.test.tsx b/src/components/EditorStepCard.test.tsx new file mode 100644 index 0000000..fc109b2 --- /dev/null +++ b/src/components/EditorStepCard.test.tsx @@ -0,0 +1,134 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import EditorStepCard from "./EditorStepCard"; +import type { Step } from "../types/step"; + +const mockConvertFileSrc = vi.mocked(convertFileSrc); + +function makeStep(overrides: Partial = {}): Step { + return { + id: "step-1", + ts: Date.now(), + action: "Click", + x: 100, + y: 200, + click_x_percent: 50, + click_y_percent: 50, + app: "Finder", + window_title: "Documents", + screenshot_path: "/tmp/screenshot.png", + note: null, + ...overrides, + }; +} + +describe("EditorStepCard", () => { + it("renders timeline badge and description", () => { + const { container } = render(); + const badge = container.querySelector(".editor-timeline-badge"); + expect(badge).toHaveTextContent("1"); + expect(screen.getByText("Clicked in Finder")).toBeInTheDocument(); + }); + + it("renders screenshot with convertFileSrc", () => { + mockConvertFileSrc.mockReturnValue("asset://localhost//tmp/screenshot.png"); + render(); + expect(mockConvertFileSrc).toHaveBeenCalledWith("/tmp/screenshot.png"); + const img = screen.getByAltText("Step 1"); + expect(img).toHaveAttribute("src", "asset://localhost//tmp/screenshot.png"); + }); + + it("shows 'Add a note...' placeholder when no note", () => { + render(); + expect(screen.getByText("Add a note...")).toBeInTheDocument(); + }); + + it("shows existing note text", () => { + render(); + expect(screen.getByText("My note")).toBeInTheDocument(); + }); + + it("enters editing mode on click and saves on blur", async () => { + const user = userEvent.setup(); + const onUpdateNote = vi.fn(); + render(); + + await user.click(screen.getByText("Add a note...")); + const textarea = screen.getByPlaceholderText("Add a note..."); + await user.type(textarea, "New note"); + await user.tab(); // blur + expect(onUpdateNote).toHaveBeenCalledWith("step-1", "New note"); + }); + + it("saves on Enter", async () => { + const user = userEvent.setup(); + const onUpdateNote = vi.fn(); + render(); + + await user.click(screen.getByText("Add a note...")); + const textarea = screen.getByPlaceholderText("Add a note..."); + await user.type(textarea, "Note text{Enter}"); + expect(onUpdateNote).toHaveBeenCalledWith("step-1", "Note text"); + }); + + it("cancels editing on Escape", async () => { + const user = userEvent.setup(); + const onUpdateNote = vi.fn(); + render(); + + await user.click(screen.getByText("Add a note...")); + const textarea = screen.getByPlaceholderText("Add a note..."); + await user.type(textarea, "Temp"); + await user.keyboard("{Escape}"); + expect(onUpdateNote).not.toHaveBeenCalled(); + // Button should be back + expect(screen.getByText("Add a note...")).toBeInTheDocument(); + }); + + it("shows click marker at correct position", () => { + const { container } = render( + , + ); + const marker = container.querySelector(".click-indicator") as HTMLElement; + expect(marker.style.left).toBe("75%"); + expect(marker.style.top).toBe("25%"); + }); + + it("hides marker for auth placeholder", () => { + const { container } = render( + , + ); + expect(container.querySelector(".click-indicator")).not.toBeInTheDocument(); + }); + + it("shows double-click description and marker", () => { + const { container } = render( + , + ); + expect(screen.getByText("Double-clicked in Finder")).toBeInTheDocument(); + expect(container.querySelector(".click-indicator.double-click")).toBeInTheDocument(); + }); + + it("shows right-click description and marker", () => { + const { container } = render( + , + ); + expect(screen.getByText("Right-clicked in Finder")).toBeInTheDocument(); + expect(container.querySelector(".click-indicator.right-click")).toBeInTheDocument(); + }); + + it("calls onDelete when delete button clicked", async () => { + const user = userEvent.setup(); + const onDelete = vi.fn(); + render(); + + await user.click(screen.getByTitle("Remove step")); + expect(onDelete).toHaveBeenCalledWith("step-1"); + }); +}); diff --git a/src/components/EditorStepCard.tsx b/src/components/EditorStepCard.tsx new file mode 100644 index 0000000..a3cc7c9 --- /dev/null +++ b/src/components/EditorStepCard.tsx @@ -0,0 +1,133 @@ +import { memo, useRef, useState } from "react"; +import { convertFileSrc } from "@tauri-apps/api/core"; +import type { Step } from "../types/step"; + +type EditorStepCardProps = { + step: Step; + index: number; + onUpdateNote: (stepId: string, note: string | null) => void; + onDelete: (stepId: string) => void; +}; + +export default memo(function EditorStepCard({ + step, + index, + onUpdateNote, + onDelete, +}: EditorStepCardProps) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(step.note ?? ""); + const textareaRef = useRef(null); + + const screenshotSrc = step.screenshot_path + ? convertFileSrc(step.screenshot_path) + : null; + + const isAuthPlaceholder = + step.window_title === "Authentication dialog (secure)" || + step.app.toLowerCase() === "authentication"; + + const actionDesc = + step.action === "DoubleClick" + ? "Double-clicked in" + : step.action === "RightClick" + ? "Right-clicked in" + : "Clicked in"; + + const description = isAuthPlaceholder + ? "Authentication required (secure dialog)" + : `${actionDesc} ${step.app}`; + + const markerClass = + step.action === "DoubleClick" + ? "click-indicator double-click" + : step.action === "RightClick" + ? "click-indicator right-click" + : "click-indicator"; + + const handleStartEdit = () => { + setDraft(step.note ?? ""); + setEditing(true); + requestAnimationFrame(() => textareaRef.current?.focus()); + }; + + const handleSave = () => { + const trimmed = draft.trim(); + onUpdateNote(step.id, trimmed || null); + setEditing(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSave(); + } + if (e.key === "Escape") { + setEditing(false); + } + }; + + return ( +
+ {/* Timeline badge */} +
{index + 1}
+ + {/* Card */} +
+
+ {description} + +
+ + {/* Screenshot with click marker */} + {screenshotSrc && ( +
+
+ {`Step + {!isAuthPlaceholder && ( +
+ )} +
+
+ )} + + {/* Note editing */} +
+ {editing ? ( +