From a659440b9fed4e86825a0bde788b7acb3da4c07b Mon Sep 17 00:00:00 2001 From: Allen Xu Date: Sun, 20 Sep 2026 17:13:55 +0800 Subject: [PATCH] fix(prompts): refresh active prompts after external file edits (#7194) Reload the enabled prompt from its live file when the Prompts list loads, and refresh the open panel when the window regains focus, so external edits to CLAUDE.md / AGENTS.md show up without a restart. - Keep saved templates available when the live file cannot be read. - Skip the backfill for empty or whitespace-only files, matching enable_prompt. - Skip the backfill while a sync/restore holds the sync lock, so a list load cannot write a stale live file into a freshly restored database. --- src-tauri/src/services/prompt.rs | 24 +++++- src-tauri/tests/prompt_live_sync.rs | 110 +++++++++++++++++++++++++ src/components/prompts/PromptPanel.tsx | 7 ++ tests/components/PromptPanel.test.tsx | 62 +++++++++----- 4 files changed, 180 insertions(+), 23 deletions(-) create mode 100644 src-tauri/tests/prompt_live_sync.rs diff --git a/src-tauri/src/services/prompt.rs b/src-tauri/src/services/prompt.rs index 7c46718c3..7338b15f0 100644 --- a/src-tauri/src/services/prompt.rs +++ b/src-tauri/src/services/prompt.rs @@ -59,7 +59,29 @@ impl PromptService { if matches!(app, AppType::Pi) { return get_pi_prompts(state); } - state.db.get_prompts(app.as_str()) + // A restore replaces the DB before projecting it to live files. Keep + // backfill outside that window, including calls outside the UI command. + let Ok(_sync_guard) = super::sync_protocol::sync_mutex().try_lock() else { + return state.db.get_prompts(app.as_str()); + }; + let mut prompts = state.db.get_prompts(app.as_str())?; + // External editors change the live file without updating the saved + // selection. Refresh only that selection; inactive templates are separate. + if let Some(prompt) = prompts.values_mut().find(|prompt| prompt.enabled) { + match Self::get_current_file_content(app.clone()) { + Ok(Some(content)) if !content.trim().is_empty() && prompt.content != content => { + prompt.content = content; + prompt.updated_at = Some(get_unix_timestamp()?); + state.db.save_prompt(app.as_str(), prompt)?; + } + Ok(_) => {} + Err(error) => log::warn!( + "Failed to refresh {} prompt from live file; keeping saved prompts: {error}", + app.as_str() + ), + } + } + Ok(prompts) } pub fn upsert_prompt( diff --git a/src-tauri/tests/prompt_live_sync.rs b/src-tauri/tests/prompt_live_sync.rs new file mode 100644 index 000000000..27d9a45bf --- /dev/null +++ b/src-tauri/tests/prompt_live_sync.rs @@ -0,0 +1,110 @@ +mod support; + +use cc_switch_lib::{AppType, Prompt, PromptService}; +use std::fs; +use support::{create_test_state, ensure_test_home, reset_test_fs, test_mutex}; + +#[test] +fn prompt_list_refreshes_external_edits_without_changing_inactive_templates() { + let _guard = test_mutex().lock().unwrap(); + reset_test_fs(); + let home = ensure_test_home(); + let state = create_test_state().unwrap(); + + for (app, relative_path) in [ + (AppType::Claude, ".claude/CLAUDE.md"), + (AppType::Codex, ".codex/AGENTS.md"), + ] { + let active = Prompt { + id: "active".into(), + name: "My instructions".into(), + content: "original".into(), + description: Some("Keep my metadata".into()), + enabled: true, + created_at: Some(1), + updated_at: Some(1), + }; + let inactive = Prompt { + id: "inactive".into(), + enabled: false, + ..active.clone() + }; + state.db.save_prompt(app.as_str(), &inactive).unwrap(); + PromptService::upsert_prompt(&state, app.clone(), &active.id, active.clone()).unwrap(); + let path = home.join(relative_path); + + for content in ["externally edited\n", "another edit\n"] { + fs::write(&path, content).unwrap(); + let prompts = PromptService::get_prompts(&state, app.clone()).unwrap(); + assert_eq!(prompts["active"].content, content); + assert_eq!(prompts["active"].name, active.name); + assert_eq!(prompts["active"].description, active.description); + assert_eq!(prompts["active"].created_at, active.created_at); + assert!(prompts["active"].enabled); + assert_eq!(prompts["inactive"].content, "original"); + assert_eq!(fs::read_to_string(&path).unwrap(), content); + assert_eq!( + state.db.get_prompts(app.as_str()).unwrap()["active"].content, + content + ); + } + + let mut saved = state.db.get_prompts(app.as_str()).unwrap()["active"].clone(); + saved.content = "saved instructions".into(); + fs::write(&path, &saved.content).unwrap(); + saved.updated_at = Some(42); + state.db.save_prompt(app.as_str(), &saved).unwrap(); + assert_eq!( + PromptService::get_prompts(&state, app.clone()).unwrap()["active"].updated_at, + Some(42) + ); + + for content in ["", " \t\r\n"] { + fs::write(&path, content).unwrap(); + let prompts = PromptService::get_prompts(&state, app.clone()).unwrap(); + assert_eq!(prompts["active"].content, saved.content); + assert_eq!(prompts["active"].updated_at, Some(42)); + assert_eq!(prompts["inactive"].content, "original"); + assert_eq!(fs::read_to_string(&path).unwrap(), content); + assert_eq!( + state.db.get_prompts(app.as_str()).unwrap()["active"].content, + saved.content + ); + } + + // An editor may save UTF-16: the live read fails, but saved templates + // must remain available so the user can repair the file from the UI. + let utf16 = [0xff, 0xfe, b'A', 0]; + fs::write(&path, utf16).unwrap(); + let prompts = PromptService::get_prompts(&state, app.clone()).unwrap(); + assert_eq!(prompts["active"].content, saved.content); + assert_eq!(prompts["active"].updated_at, Some(42)); + assert_eq!(prompts["inactive"].content, "original"); + assert_eq!(fs::read(&path).unwrap(), utf16); + assert_eq!( + state.db.get_prompts(app.as_str()).unwrap()["active"].updated_at, + Some(42) + ); + assert_eq!( + state.db.get_prompts(app.as_str()).unwrap()["active"].content, + saved.content + ); + assert!(PromptService::get_current_file_content(app.clone()).is_err()); + + fs::remove_file(&path).unwrap(); + assert_eq!( + PromptService::get_prompts(&state, app.clone()).unwrap()["active"].updated_at, + Some(42) + ); + assert!(!path.exists()); + + saved.enabled = false; + state.db.save_prompt(app.as_str(), &saved).unwrap(); + fs::write(&path, "unmanaged instructions").unwrap(); + assert_eq!( + PromptService::get_prompts(&state, app).unwrap()["active"].content, + saved.content + ); + assert_eq!(fs::read_to_string(path).unwrap(), "unmanaged instructions"); + } +} diff --git a/src/components/prompts/PromptPanel.tsx b/src/components/prompts/PromptPanel.tsx index 3e2eba389..e59ce4bad 100644 --- a/src/components/prompts/PromptPanel.tsx +++ b/src/components/prompts/PromptPanel.tsx @@ -132,6 +132,13 @@ const StandardPromptPanel = React.forwardRef< if (open) void runExternalReload(); }, [appId, open, runExternalReload]); + useEffect(() => { + if (!open) return; + const handleFocus = () => void runExternalReload(); + window.addEventListener("focus", handleFocus); + return () => window.removeEventListener("focus", handleFocus); + }, [open, runExternalReload]); + useEffect(() => { setSearchQuery(""); overlayOpenRef.current = false; diff --git a/tests/components/PromptPanel.test.tsx b/tests/components/PromptPanel.test.tsx index b99ae98f9..6a675dead 100644 --- a/tests/components/PromptPanel.test.tsx +++ b/tests/components/PromptPanel.test.tsx @@ -475,36 +475,54 @@ describe("PromptPanel", () => { expect(onNavigationBlockedChange).toHaveBeenLastCalledWith(false); }); - it("queues external reloads while an edit or confirmation is open", async () => { - renderPanel(); + it("refreshes on window focus and removes the listener on unmount", async () => { + const { unmount } = renderPanel(); await waitForPanelReady(); mocks.reload.mockClear(); - fireEvent.click(screen.getAllByTitle("common.edit")[0]); - act(() => { - window.dispatchEvent( - new CustomEvent("prompt-imported", { detail: { app: "claude" } }), - ); - }); - expect(mocks.reload).not.toHaveBeenCalled(); - - fireEvent.click(screen.getByRole("button", { name: "form-close" })); + fireEvent(window, new Event("focus")); await waitFor(() => expect(mocks.reload).toHaveBeenCalledTimes(1)); - mocks.reload.mockClear(); await waitForPanelReady(); - - fireEvent.click(screen.getAllByTitle("common.delete")[0]); - act(() => { - window.dispatchEvent( - new CustomEvent("prompt-imported", { detail: { app: "claude" } }), - ); - }); + unmount(); + mocks.reload.mockClear(); + fireEvent(window, new Event("focus")); expect(mocks.reload).not.toHaveBeenCalled(); - - fireEvent.click(screen.getByRole("button", { name: "cancel-dialog" })); - await waitFor(() => expect(mocks.reload).toHaveBeenCalledTimes(1)); }); + it.each(["focus", "prompt-imported"])( + "queues %s reloads while an edit or confirmation is open", + async (trigger) => { + renderPanel(); + await waitForPanelReady(); + mocks.reload.mockClear(); + + fireEvent.click(screen.getAllByTitle("common.edit")[0]); + fireEvent( + window, + trigger === "focus" + ? new Event("focus") + : new CustomEvent("prompt-imported", { detail: { app: "claude" } }), + ); + expect(mocks.reload).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "form-close" })); + await waitFor(() => expect(mocks.reload).toHaveBeenCalledTimes(1)); + mocks.reload.mockClear(); + await waitForPanelReady(); + + fireEvent.click(screen.getAllByTitle("common.delete")[0]); + act(() => { + window.dispatchEvent( + new CustomEvent("prompt-imported", { detail: { app: "claude" } }), + ); + }); + expect(mocks.reload).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "cancel-dialog" })); + await waitFor(() => expect(mocks.reload).toHaveBeenCalledTimes(1)); + }, + ); + it("starts the latest app reload without waiting for an older app", async () => { let resolveClaudeReload!: () => void; const claudeReload = vi.fn(