mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
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.
This commit is contained in:
1 parent
33c806269f
commit
a659440b9f
4 files changed
+180
-23
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user