From 105ac677803d90f4b8894298778de60ac726d2dd Mon Sep 17 00:00:00 2001 From: hermer29 Date: Mon, 12 Oct 2026 23:30:41 +0300 Subject: [PATCH 1/2] feat: add compact Zen Mode and task renaming Cycle unfinished tasks in a content-sized Zen window, restore normal geometry on exit, and adapt task actions to available title width. Persist renamed titles through the existing sync patch and translate new UI across all locales. Validated: frontend lint/types/build and 163 tests; desktop fmt/Clippy and 135 tests; server fmt/Clippy and 23 tests. Browser layout and native shrink/restore checked. Native geometry has no automated WebviewWindow tests. OpenSSL test linking reports existing LNK4099 debug-symbol warnings; Clippy is clean. Physical Android, live VDS rename sync, and cross-monitor DPI acceptance remain pending. --- README.md | 12 +++ .../desktop/src-tauri/src/commands/overlay.rs | 21 +++++ .../src-tauri/src/commands/plan_items.rs | 20 +++++ apps/desktop/src-tauri/src/lib.rs | 5 ++ apps/desktop/src-tauri/src/plan_items.rs | 34 ++++++++ .../src-tauri/src/plan_items/rename.rs | 35 ++++++++ apps/desktop/src-tauri/src/window_state.rs | 5 +- apps/desktop/src-tauri/src/zen_window.rs | 79 +++++++++++++++++++ apps/desktop/src/App.css | 1 + apps/desktop/src/App.test.tsx | 17 ++++ apps/desktop/src/App.tsx | 18 +++-- .../src/__snapshots__/App.test.tsx.snap | 40 ++++++++++ apps/desktop/src/components/DayView.tsx | 8 ++ apps/desktop/src/components/OverlayHeader.tsx | 8 +- .../src/components/PlanItemDetailsDialog.tsx | 5 +- apps/desktop/src/components/PlanItemList.tsx | 12 ++- .../components/PlanItemRenameDialog.test.tsx | 33 ++++++++ .../src/components/PlanItemRenameDialog.tsx | 36 +++++++++ .../components/WindowResizeHandles.test.tsx | 5 ++ .../src/components/WindowResizeHandles.tsx | 9 ++- apps/desktop/src/components/ZenHeader.tsx | 13 +++ apps/desktop/src/components/ZenView.test.tsx | 52 ++++++++++++ apps/desktop/src/components/ZenView.tsx | 44 +++++++++++ apps/desktop/src/shared/commands/overlay.ts | 13 ++- apps/desktop/src/shared/commands/planItems.ts | 3 + apps/desktop/src/shared/translations/de.ts | 6 ++ apps/desktop/src/shared/translations/en.ts | 6 ++ apps/desktop/src/shared/translations/es.ts | 6 ++ apps/desktop/src/shared/translations/fr.ts | 6 ++ apps/desktop/src/shared/translations/hi.ts | 6 ++ apps/desktop/src/shared/translations/ja.ts | 6 ++ apps/desktop/src/shared/translations/ko.ts | 6 ++ apps/desktop/src/shared/translations/pt.ts | 6 ++ apps/desktop/src/shared/translations/ru.ts | 6 ++ apps/desktop/src/shared/translations/zh.ts | 6 ++ .../src/shared/usePlanItemLayout.test.tsx | 52 ++++++++++++ apps/desktop/src/shared/usePlanItemLayout.ts | 33 ++++++++ apps/desktop/src/shared/usePlanItems.ts | 1 + apps/desktop/src/shared/useZenMode.test.ts | 60 ++++++++++++++ apps/desktop/src/shared/useZenMode.ts | 30 +++++++ apps/desktop/src/shared/useZenQueue.test.ts | 49 ++++++++++++ apps/desktop/src/shared/useZenQueue.ts | 15 ++++ .../src/shared/useZenWindowHeight.test.tsx | 52 ++++++++++++ apps/desktop/src/shared/useZenWindowHeight.ts | 39 +++++++++ apps/desktop/src/styles/plan-items.css | 45 ++++++++++- apps/desktop/src/styles/zen.css | 21 +++++ docs/code-structure.md | 25 ++++++ docs/v1-release-plan.md | 26 ++++++ 48 files changed, 1017 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/src-tauri/src/plan_items/rename.rs create mode 100644 apps/desktop/src-tauri/src/zen_window.rs create mode 100644 apps/desktop/src/components/PlanItemRenameDialog.test.tsx create mode 100644 apps/desktop/src/components/PlanItemRenameDialog.tsx create mode 100644 apps/desktop/src/components/ZenHeader.tsx create mode 100644 apps/desktop/src/components/ZenView.test.tsx create mode 100644 apps/desktop/src/components/ZenView.tsx create mode 100644 apps/desktop/src/shared/usePlanItemLayout.test.tsx create mode 100644 apps/desktop/src/shared/usePlanItemLayout.ts create mode 100644 apps/desktop/src/shared/useZenMode.test.ts create mode 100644 apps/desktop/src/shared/useZenMode.ts create mode 100644 apps/desktop/src/shared/useZenQueue.test.ts create mode 100644 apps/desktop/src/shared/useZenQueue.ts create mode 100644 apps/desktop/src/shared/useZenWindowHeight.test.tsx create mode 100644 apps/desktop/src/shared/useZenWindowHeight.ts create mode 100644 apps/desktop/src/styles/zen.css diff --git a/README.md b/README.md index 739dd1e..9397a5d 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,18 @@ Implemented today: plaintext-vault migration support on desktop. - Today, Notes, Reminders, Settings, profile switching, and diagnostics UI, shared between desktop and Android. +- Desktop Zen Mode (focus icon beside pin) shows unfinished tasks from today's + list, including long-running tasks, one at a time. Next cycles through the + unfinished tasks without changing their status; completed tasks leave the ring. + The final screen says "All done for today" only when no unfinished tasks remain. + Zen Mode hides the normal header and fits window height to the task and Next + button (72–180 px limits), leaving a 4 px gap without unused space underneath, + with an exit button at the top right. Exiting restores the previous window size; + compact geometry is not persisted as the normal size. Task titles wrap, and + task lists and rename dialogs use the same thin scrollbar style as Settings. + Task actions stay beside the title when both fit; otherwise they move above it. + Tasks can be renamed from their inline actions + or details dialog; title changes are persisted and included in VDS sync. - Audio note capture with encrypted desktop audio blobs. - Reminder alert window with snooze and acknowledge actions. - VDS sync server: accounts, device linking, operation-log exchange, diff --git a/apps/desktop/src-tauri/src/commands/overlay.rs b/apps/desktop/src-tauri/src/commands/overlay.rs index d89aab0..289962b 100644 --- a/apps/desktop/src-tauri/src/commands/overlay.rs +++ b/apps/desktop/src-tauri/src/commands/overlay.rs @@ -3,6 +3,27 @@ use tauri_plugin_opener::OpenerExt; const PRIVACY_POLICY_URL: &str = "https://focus.proanima.net/privacy"; +#[tauri::command] +pub(crate) fn get_zen_mode() -> bool { + crate::zen_window::is_active() +} + +#[tauri::command] +pub(crate) fn resize_zen_window(window: tauri::WebviewWindow, height: u32) -> Result<(), String> { + if window.label() != "main" { + return Err("Zen Mode is available in the main window only".into()); + } + crate::zen_window::resize_to_content(&window, height) +} + +#[tauri::command] +pub(crate) fn set_zen_mode(window: tauri::WebviewWindow, enabled: bool) -> Result<(), String> { + if window.label() != "main" { + return Err("Zen Mode is available in the main window only".into()); + } + crate::zen_window::set_mode(&window, enabled) +} + #[tauri::command] pub(crate) fn open_privacy_policy(app: tauri::AppHandle) -> Result<(), String> { app.opener() diff --git a/apps/desktop/src-tauri/src/commands/plan_items.rs b/apps/desktop/src-tauri/src/commands/plan_items.rs index 1a9a1a6..56ac53c 100644 --- a/apps/desktop/src-tauri/src/commands/plan_items.rs +++ b/apps/desktop/src-tauri/src/commands/plan_items.rs @@ -1,6 +1,26 @@ use super::trigger_server_sync; use crate::{db, plan_items, profiles, sync_log}; +#[tauri::command] +pub(crate) fn rename_plan_item( + app: tauri::AppHandle, + db: tauri::State, + hlc_state: tauri::State, + profiles_state: tauri::State, + id: String, + title: String, +) -> Result { + let mut conn = db.0.lock().map_err(|e| e.to_string())?; + let mut clock = hlc_state.0.lock().map_err(|e| e.to_string())?; + let profile_id = profiles::active_profile_id(&profiles_state)?; + let item = plan_items::rename(&mut conn, &mut clock, &profile_id, &id, &title) + .map_err(|e| e.to_string())?; + drop(clock); + drop(conn); + trigger_server_sync(&app); + Ok(item) +} + #[tauri::command] pub(crate) fn list_plan_items( db: tauri::State, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 4ed947a..14a1c0d 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -26,6 +26,7 @@ mod sync_status; #[cfg(feature = "cloud-providers")] mod sync_tokens; mod window_state; +mod zen_window; use serde::Serialize; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; @@ -332,6 +333,10 @@ pub fn run() { commands::plan_items::list_plan_items, commands::plan_items::list_plan_items_range, commands::plan_items::create_plan_item, + commands::plan_items::rename_plan_item, + commands::overlay::set_zen_mode, + commands::overlay::get_zen_mode, + commands::overlay::resize_zen_window, commands::plan_items::toggle_plan_item_done, commands::plan_items::cycle_plan_item_progress, commands::plan_items::toggle_plan_item_deferred, diff --git a/apps/desktop/src-tauri/src/plan_items.rs b/apps/desktop/src-tauri/src/plan_items.rs index f3721a3..c9b7380 100644 --- a/apps/desktop/src-tauri/src/plan_items.rs +++ b/apps/desktop/src-tauri/src/plan_items.rs @@ -2,6 +2,9 @@ use crate::sync_log::{self, HlcClock}; use rusqlite::{params, Connection, Row}; use serde::Serialize; +mod rename; +pub use rename::rename; + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct PlanItemDto { @@ -391,6 +394,37 @@ mod tests { .unwrap() } + #[test] + fn rename_preserves_state_and_records_title_patch() { + let mut conn = setup_conn(); + let mut clock = test_clock(); + let item = create(&mut conn, &mut clock, PROFILE, "Task", TODAY).unwrap(); + toggle_long_running(&mut conn, &mut clock, PROFILE, &item.id).unwrap(); + cycle_progress(&mut conn, &mut clock, PROFILE, &item.id).unwrap(); + let renamed = rename(&mut conn, &mut clock, PROFILE, &item.id, " Updated ").unwrap(); + assert_eq!(renamed.title, "Updated"); + assert_eq!(renamed.status, "partial"); + assert_eq!(renamed.progress_percent, Some(25)); + assert!(renamed.is_long_running); + assert_eq!(renamed.plan_date, TODAY); + let patch: String = conn + .query_row( + "SELECT patch FROM sync_operations WHERE entity_id = ?1 ORDER BY hlc DESC LIMIT 1", + params![item.id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + serde_json::from_str::(&patch).unwrap(), + serde_json::json!({"title": "Updated"}) + ); + let count = operation_count(&conn); + assert!(rename(&mut conn, &mut clock, PROFILE, &item.id, " ").is_err()); + assert!(rename(&mut conn, &mut clock, PROFILE, "missing", "Title").is_err()); + assert_eq!(operation_count(&conn), count); + assert_eq!(fetch(&conn, &item.id).unwrap().title, "Updated"); + } + #[test] fn create_starts_open_with_date_and_no_progress() { let mut conn = setup_conn(); diff --git a/apps/desktop/src-tauri/src/plan_items/rename.rs b/apps/desktop/src-tauri/src/plan_items/rename.rs new file mode 100644 index 0000000..7bc0b96 --- /dev/null +++ b/apps/desktop/src-tauri/src/plan_items/rename.rs @@ -0,0 +1,35 @@ +use super::{fetch, PlanItemDto}; +use crate::sync_log::{self, HlcClock}; +use rusqlite::{params, Connection}; + +pub fn rename( + conn: &mut Connection, + clock: &mut HlcClock, + profile_id: &str, + id: &str, + title: &str, +) -> rusqlite::Result { + let title = title.trim(); + if title.is_empty() { + return Err(rusqlite::Error::InvalidParameterName("title".into())); + } + let tx = conn.transaction()?; + fetch(&tx, id)?; + tx.execute( + "UPDATE plan_items SET title = ?1 WHERE id = ?2", + params![title, id], + )?; + let hlc = clock.next(&tx)?; + sync_log::record_operation( + &tx, + &hlc, + profile_id, + "plan_item", + id, + "update", + &serde_json::json!({ "title": title }), + )?; + let dto = fetch(&tx, id)?; + tx.commit()?; + Ok(dto) +} diff --git a/apps/desktop/src-tauri/src/window_state.rs b/apps/desktop/src-tauri/src/window_state.rs index cd0a5a2..8ec7248 100644 --- a/apps/desktop/src-tauri/src/window_state.rs +++ b/apps/desktop/src-tauri/src/window_state.rs @@ -127,6 +127,9 @@ pub fn reapply_size_after_scale_change( data_dir: &Path, scale_factor: f64, ) { + if crate::zen_window::is_active() { + return; + } let Some(state) = load_state(data_dir) else { return; }; @@ -134,7 +137,7 @@ pub fn reapply_size_after_scale_change( } pub fn save(window: &tauri::WebviewWindow, data_dir: &Path) -> Result<(), String> { - if window.label() != "main" { + if window.label() != "main" || crate::zen_window::is_active() { return Ok(()); } let physical_size = window.outer_size().map_err(|e| e.to_string())?; diff --git a/apps/desktop/src-tauri/src/zen_window.rs b/apps/desktop/src-tauri/src/zen_window.rs new file mode 100644 index 0000000..3196137 --- /dev/null +++ b/apps/desktop/src-tauri/src/zen_window.rs @@ -0,0 +1,79 @@ +//! Temporary desktop geometry; normal window persistence is suspended during Zen Mode. +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Mutex, +}; +use tauri::{LogicalSize, WebviewWindow}; + +static ACTIVE: AtomicBool = AtomicBool::new(false); +static NORMAL_SIZE: Mutex>> = Mutex::new(None); + +pub fn is_active() -> bool { + ACTIVE.load(Ordering::SeqCst) +} + +fn apply_geometry(window: &WebviewWindow, zen: bool, size: LogicalSize) -> tauri::Result<()> { + let position = window.outer_position()?; + window.set_max_size(None::>)?; + window.set_min_size(Some(LogicalSize::new( + 280.0, + if zen { 72.0 } else { 260.0 }, + )))?; + window.set_size(size)?; + window.set_max_size(Some(LogicalSize::new( + 520.0, + if zen { 180.0 } else { 1200.0 }, + )))?; + window.set_position(position)?; + Ok(()) +} + +pub fn set_mode(window: &WebviewWindow, enabled: bool) -> Result<(), String> { + let mut normal = NORMAL_SIZE + .lock() + .map_err(|_| "window state unavailable".to_string())?; + if enabled == is_active() { + return Ok(()); + } + if enabled { + let size = window + .inner_size() + .map_err(|e| e.to_string())? + .to_logical::(window.scale_factor().map_err(|e| e.to_string())?); + ACTIVE.store(true, Ordering::SeqCst); + if let Err(error) = apply_geometry(window, true, LogicalSize::new(size.width, 96.0)) { + if apply_geometry(window, false, size).is_err() { + log::warn!("failed to restore normal window geometry after Zen transition"); + } + ACTIVE.store(false, Ordering::SeqCst); + return Err(error.to_string()); + } + *normal = Some(size); + } else if let Some(size) = *normal { + apply_geometry(window, false, size).map_err(|e| e.to_string())?; + ACTIVE.store(false, Ordering::SeqCst); + *normal = None; + } + Ok(()) +} + +pub fn resize_to_content(window: &WebviewWindow, height: u32) -> Result<(), String> { + let _normal = NORMAL_SIZE + .lock() + .map_err(|_| "window state unavailable".to_string())?; + if !is_active() { + return Ok(()); + } + let size = window + .inner_size() + .map_err(|e| e.to_string())? + .to_logical::(window.scale_factor().map_err(|e| e.to_string())?); + let position = window.outer_position().map_err(|e| e.to_string())?; + window + .set_size(LogicalSize::new( + size.width, + f64::from(height.clamp(72, 180)), + )) + .map_err(|e| e.to_string())?; + window.set_position(position).map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src/App.css b/apps/desktop/src/App.css index 4c5e4ca..19db2fb 100644 --- a/apps/desktop/src/App.css +++ b/apps/desktop/src/App.css @@ -2,6 +2,7 @@ @import "./styles/shell.css"; @import "./styles/profile.css"; @import "./styles/plan-items.css"; +@import "./styles/zen.css"; @import "./styles/notes.css"; @import "./styles/reminders.css"; @import "./styles/settings.css"; diff --git a/apps/desktop/src/App.test.tsx b/apps/desktop/src/App.test.tsx index 03e7e2b..6eec8ea 100644 --- a/apps/desktop/src/App.test.tsx +++ b/apps/desktop/src/App.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import App from "./App"; // Раздел 21 ТЗ: "layout snapshots для compact desktop/mobile" — снимок всей @@ -12,6 +13,8 @@ vi.mock("./shared/commands", () => { return { commands: { overlay: { + getZenMode: () => Promise.resolve(false), + resizeZenWindow: () => Promise.resolve(), toggle: reject, onLayerChanged: () => Promise.resolve(() => {}), getShortcutStatus: () => Promise.resolve(null), @@ -104,6 +107,20 @@ function normalizeDynamicDate(container: HTMLElement) { } describe("App layout snapshots", () => { + it("enters Zen Mode from the header and restores navigation on exit", async () => { + isDesktop.mockResolvedValue(true); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole("button", { name: "Включить Zen Mode" })); + expect(await screen.findByText("На сегодня всё")).toBeInTheDocument(); + expect(screen.queryByText("FocusNook")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Убрать с переднего плана" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Настройки" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Закрыть" })).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText("Добавить дело...")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Выйти из Zen Mode" })); + expect(await screen.findByText("На сегодня пока ничего не запланировано")).toBeInTheDocument(); + }); it("matches the desktop shell layout", async () => { isDesktop.mockResolvedValue(true); const { container } = render(); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 08ae438..7bbdaa7 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -19,6 +19,9 @@ import { useTheme } from "./shared/useTheme"; import type { ResolvedTheme } from "./shared/theme-context"; import { SettingsPanel } from "./components/SettingsPanel"; import { DayView } from "./components/DayView"; +import { ZenView } from "./components/ZenView"; +import { useZenMode } from "./shared/useZenMode"; +import { ZenHeader } from "./components/ZenHeader"; import { NotesView } from "./components/NotesView"; import { RemindersView } from "./components/RemindersView"; import { ReminderAlert } from "./components/ReminderAlert"; @@ -86,23 +89,26 @@ interface DesktopShellProps { function DesktopShell({ front, toggleLayer, shortcutInfo, theme, folderRailSide, accounts }: DesktopShellProps) { const [activeTab, setActiveTab] = useState("day"); const [showSettings, setShowSettings] = useState(false); + const { active: zenMode, toggle: toggleZen } = useZenMode(); const { activeProfileId, activeProfile, logout } = accounts; const mainTabs = useMainTabs(); return ( -
+
- void toggleZen()} /> : { void toggleZen(); setShowSettings(false); }} front={front} onToggleLayer={toggleLayer} showSettings={showSettings} onToggleSettings={() => setShowSettings((value) => !value)} account={activeProfile!} onLogout={() => void logout()} - /> + />} - {!showSettings && } + {!showSettings && !zenMode && }
{showSettings ? ( @@ -111,6 +117,8 @@ function DesktopShell({ front, toggleLayer, shortcutInfo, theme, folderRailSide, onClose={() => setShowSettings(false)} isDesktop /> + ) : zenMode ? ( + ) : ( )}
- +
); diff --git a/apps/desktop/src/__snapshots__/App.test.tsx.snap b/apps/desktop/src/__snapshots__/App.test.tsx.snap index eb1e6a4..e481128 100644 --- a/apps/desktop/src/__snapshots__/App.test.tsx.snap +++ b/apps/desktop/src/__snapshots__/App.test.tsx.snap @@ -39,8 +39,47 @@ exports[`App layout snapshots > matches the desktop shell layout 1`] = `
+ @@ -27,6 +28,8 @@ function HeaderButton({ } interface OverlayHeaderProps { + zenMode: boolean; + onToggleZen: () => void; front: boolean; onToggleLayer: () => void; showSettings: boolean; @@ -38,6 +41,8 @@ interface OverlayHeaderProps { // Только для десктопа (см. App.tsx::DesktopShell) — на Android своя шапка // (MobileShell), always-on-top и это меню там не нужны. export function OverlayHeader({ + zenMode, + onToggleZen, front, onToggleLayer, showSettings, @@ -56,6 +61,7 @@ export function OverlayHeader({ FocusNook
+ void; onToggleLongRunning: () => void; onCycleProgress: () => void; onToggleDeferred: () => void; @@ -54,6 +56,7 @@ export function PlanItemDetailsDialog({ return (

{item.title}

+ {item.status !== "partial" && ( {item.status === "partial" && ( diff --git a/apps/desktop/src/components/PlanItemRenameDialog.test.tsx b/apps/desktop/src/components/PlanItemRenameDialog.test.tsx new file mode 100644 index 0000000..ea99888 --- /dev/null +++ b/apps/desktop/src/components/PlanItemRenameDialog.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { PlanItemRenameDialog } from "./PlanItemRenameDialog"; +import type { PlanItem } from "../shared/commands"; + +const item: PlanItem = { id: "test", title: "Old", status: "open", planDate: "2026-10-12", progressPercent: null, isLongRunning: false }; +describe("PlanItemRenameDialog", () => { + it("prefills, trims and saves a title", async () => { + const user = userEvent.setup(); + const save = vi.fn().mockResolvedValue({ ...item, title: "New" }); + const close = vi.fn(); + render(); + const input = screen.getByRole("textbox", { name: "Переименовать" }); + expect(input).toHaveValue("Old"); + await user.clear(input); + expect(screen.getByRole("button", { name: "Сохранить" })).toBeDisabled(); + await user.type(input, " New "); + await user.click(screen.getByRole("button", { name: "Сохранить" })); + expect(save).toHaveBeenCalledWith("test", "New"); + expect(close).toHaveBeenCalledOnce(); + }); + it("keeps the draft when saving fails and allows retry", async () => { + const user = userEvent.setup(); + const save = vi.fn().mockResolvedValue(null); + const close = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "Сохранить" })); + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(close).not.toHaveBeenCalled(); + expect(screen.getByRole("textbox")).toHaveValue("Old"); + }); +}); diff --git a/apps/desktop/src/components/PlanItemRenameDialog.tsx b/apps/desktop/src/components/PlanItemRenameDialog.tsx new file mode 100644 index 0000000..5091b6a --- /dev/null +++ b/apps/desktop/src/components/PlanItemRenameDialog.tsx @@ -0,0 +1,36 @@ +import { useState, type FormEvent } from "react"; +import type { PlanItem } from "../shared/commands"; +import { useLocale } from "../shared/useLocale"; +import { ItemDetailsDialog } from "./ItemDetailsDialog"; + +export function PlanItemRenameDialog({ item, onSave, onClose }: { + item: PlanItem; + onSave: (id: string, title: string) => Promise; + onClose: () => void; +}) { + const { t } = useLocale(); + const [title, setTitle] = useState(item.title); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(false); + async function submit(event: FormEvent) { + event.preventDefault(); + if (!title.trim() || saving) return; + setSaving(true); + const updated = await onSave(item.id, title.trim()); + setSaving(false); + if (updated) onClose(); + else setError(true); + } + return ( + + void submit(event)}> + + {error &&

{t("day.renameError")}

} +
+ + +
+ +
+ ); +} diff --git a/apps/desktop/src/components/WindowResizeHandles.test.tsx b/apps/desktop/src/components/WindowResizeHandles.test.tsx index 64bb82e..6cc45c4 100644 --- a/apps/desktop/src/components/WindowResizeHandles.test.tsx +++ b/apps/desktop/src/components/WindowResizeHandles.test.tsx @@ -33,4 +33,9 @@ describe("WindowResizeHandles", () => { expect(resizeBy).toHaveBeenCalledWith(16, 0); }); + it("uses compact height limits for Zen keyboard resizing", () => { + render(); + fireEvent.keyDown(screen.getByRole("button", { name: "header.resize" }), { key: "ArrowDown" }); + expect(resizeBy).toHaveBeenCalledWith(0, 16, true); + }); }); diff --git a/apps/desktop/src/components/WindowResizeHandles.tsx b/apps/desktop/src/components/WindowResizeHandles.tsx index 6aeeb5f..ef7e139 100644 --- a/apps/desktop/src/components/WindowResizeHandles.tsx +++ b/apps/desktop/src/components/WindowResizeHandles.tsx @@ -18,7 +18,7 @@ function startResize(direction: ResizeDirection, event: PointerEvent) { void commands.overlay.startResize(direction); } -function keyboardResize(event: KeyboardEvent) { +function keyboardResize(event: KeyboardEvent, zenMode: boolean) { const step = event.shiftKey ? 48 : 16; const delta = { ArrowLeft: [-step, 0], @@ -28,10 +28,11 @@ function keyboardResize(event: KeyboardEvent) { }[event.key]; if (!delta) return; event.preventDefault(); - void commands.overlay.resizeBy(delta[0], delta[1]); + if (zenMode) void commands.overlay.resizeBy(delta[0], delta[1], true); + else void commands.overlay.resizeBy(delta[0], delta[1]); } -export function WindowResizeHandles() { +export function WindowResizeHandles({ zenMode = false }: { zenMode?: boolean }) { const { t } = useLocale(); return ( <> @@ -50,7 +51,7 @@ export function WindowResizeHandles() { type="button" aria-label={t("header.resize")} title={t("header.resize")} - onKeyDown={keyboardResize} + onKeyDown={(event) => keyboardResize(event, zenMode)} onPointerDown={(event) => startResize("SouthEast", event)} >