From 5f6e2a9c85d486c82a5cfb1ff32930f9d09ac0da Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:26:04 +0800 Subject: [PATCH] fix(workbench): Reset project state when a window switches projects Opening another project in the same window left the previous project's state in module-level stores: Amend and the commit message carried over, debug sessions kept running, and Git Log, usages, hierarchy, inspection, HTTP client, recent locations, navigation history and notifications still showed the old project. Reset each of them on a project root change, stop the old debug sessions, ask about unsaved changes before leaving a project, close the settings window (it would apply to the new project) and end the indexing task when a scan is cancelled. --- crates/ide-index/src/worker.rs | 17 ++++++++++-- src-tauri/src/commands/app.rs | 5 ++++ src-tauri/src/windows.rs | 7 +++++ src/editor/core/controller.ts | 2 ++ src/editor/core/model/editHistory.ts | 5 ++++ src/editor/core/model/navHistory.ts | 6 +++++ src/features/analysis/duplicates.ts | 13 ++++++++-- src/features/analysis/index.ts | 8 ++++++ src/features/analysis/inspect.ts | 13 ++++++++-- src/features/app/closeGuard.ts | 12 ++++++++- src/features/app/windows.ts | 3 ++- src/features/code-insight/hierarchy.ts | 11 ++++++++ src/features/code-insight/index.ts | 6 +++-- src/features/code-insight/usages.ts | 6 +++++ src/features/database/index.ts | 3 +++ src/features/debugger/index.ts | 3 ++- src/features/debugger/state.ts | 9 +++++++ src/features/github/index.ts | 6 ++--- src/features/github/store.ts | 6 +++++ src/features/http-client/index.ts | 6 ++++- src/features/http-client/store.ts | 6 +++++ src/features/run/index.ts | 3 ++- src/features/search/common/tracking.ts | 5 ++++ src/features/search/index.ts | 10 ++++--- .../vcs/__tests__/projectSwitch.test.ts | 26 +++++++++++++++++++ src/features/vcs/commitStore.ts | 14 ++++++++++ src/features/vcs/logStore.ts | 5 ++-- src/platform/project.ts | 15 +++++++++++ src/workbench/activate.ts | 7 +++-- src/workbench/shellActions.ts | 4 +-- 30 files changed, 216 insertions(+), 26 deletions(-) create mode 100644 src/features/vcs/__tests__/projectSwitch.test.ts diff --git a/crates/ide-index/src/worker.rs b/crates/ide-index/src/worker.rs index 4acc370d0..1e25d4b56 100644 --- a/crates/ide-index/src/worker.rs +++ b/crates/ide-index/src/worker.rs @@ -172,6 +172,19 @@ impl Shared { r.truncate(MAX_RECENT); } + /// A cancelled scan (project closed) still ends its progress task and the indexing state. + fn end_scan(&self, task: TaskId) { + self.indexing.store(false, Ordering::SeqCst); + self.sink.emit(IdeEvent::Task { + id: task, + title: "Indexing".into(), + message: None, + fraction: None, + done: true, + }); + self.emit_state(); + } + fn full_scan(&self) { let started = Instant::now(); self.indexing.store(true, Ordering::SeqCst); @@ -187,7 +200,7 @@ impl Shared { let found = self.filter.walk(self.filter.root(), &self.cancelled); if self.is_cancelled() { - return; + return self.end_scan(task); } let seen: HashSet<&Path> = found.iter().map(|f| f.path.as_path()).collect(); let (todo, stale): (Vec<&Found>, Vec) = { @@ -212,7 +225,7 @@ impl Shared { let mut last_progress = Instant::now(); for chunk in todo.chunks(CHUNK) { if self.is_cancelled() { - return; + return self.end_scan(task); } self.index_files(chunk.iter().copied()); done += chunk.len(); diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 0c7723cb4..34ed04aff 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -56,6 +56,10 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult().project(&label).is_ok() { + crate::windows::close_settings_window(&app, &label); + } let project = tauri::async_runtime::spawn_blocking(move || app.state::().open_project(&label, &path)) .await .map_err(|e| IdeError::internal(e.to_string()))??; @@ -76,6 +80,7 @@ fn project_info(project: &crate::state::Project) -> ProjectInfo { #[tauri::command] #[specta::specta] pub fn close_project(window: Window, state: State<'_, AppState>) { + crate::windows::close_settings_window(window.app_handle(), window.label()); state.close_project(window.label()); } diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index e350b8d14..6418f3aaa 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -108,6 +108,13 @@ pub fn settings_label(parent: &str) -> String { format!("{SETTINGS_PREFIX}{parent}") } +/// Closes `parent`'s settings window: it shows (and would apply to) the project the parent is leaving. +pub fn close_settings_window(app: &AppHandle, parent: &str) { + if let Some(w) = app.get_webview_window(&settings_label(parent)) { + let _ = w.close(); + } +} + /// Project windows (everything but settings windows), for counting and cycling. pub fn project_window_labels(app: &AppHandle) -> Vec { sorted_labels(app.webview_windows().keys().map(String::as_str).filter(|l| !is_settings_window(l))) diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index 12cfde1f2..dec6394f5 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -192,6 +192,8 @@ export class EditorController implements SlotHost { this.saveSession(); this.sessionRoot = null; this.closeEverything(); + this.nav.clear(); + this.edits.clear(); if (root) this.restoreSession(root); } }), diff --git a/src/editor/core/model/editHistory.ts b/src/editor/core/model/editHistory.ts index e32e6a551..4cb22e7e6 100644 --- a/src/editor/core/model/editHistory.ts +++ b/src/editor/core/model/editHistory.ts @@ -46,6 +46,11 @@ export class EditHistory { return this.entries[this.index]!; } + clear() { + this.entries = []; + this.index = 0; + } + /** Drops the places of a deleted file. */ removePath(path: string) { const before = this.entries.length; diff --git a/src/editor/core/model/navHistory.ts b/src/editor/core/model/navHistory.ts index 2716cb177..b843e8843 100644 --- a/src/editor/core/model/navHistory.ts +++ b/src/editor/core/model/navHistory.ts @@ -71,6 +71,12 @@ export class NavigationHistory { this.emit(); } + clear() { + this.entries = []; + this.index = -1; + this.emit(); + } + snapshot() { return { entries: this.entries.map((e) => ({ ...e })), index: this.index }; } diff --git a/src/features/analysis/duplicates.ts b/src/features/analysis/duplicates.ts index 78061d98d..66619114b 100644 --- a/src/features/analysis/duplicates.ts +++ b/src/features/analysis/duplicates.ts @@ -49,14 +49,16 @@ interface DuplicatesState { last: { choice: ScopeChoice; options: DuplicateOptions } | null; } -export const useDuplicates = create(() => ({ +const initialDuplicates = (): DuplicatesState => ({ running: false, report: null, scopeLabel: null, error: null, selected: null, last: null, -})); +}); + +export const useDuplicates = create(initialDuplicates); let inflight: RequestId | null = null; @@ -64,6 +66,13 @@ export function cancelDuplicates() { if (inflight !== null) void commands.cancelRequest(inflight).catch(() => undefined); } +/** The window switched projects: stops the run and forgets its report. */ +export function resetDuplicates() { + cancelDuplicates(); + inflight = null; + useDuplicates.setState(initialDuplicates()); +} + export async function locateDuplicates(choice: ScopeChoice, options: DuplicateOptions): Promise { const scope = await resolveScope(choice); if (typeof scope === "string") { diff --git a/src/features/analysis/index.ts b/src/features/analysis/index.ts index 9e1029687..a70ce1eb2 100644 --- a/src/features/analysis/index.ts +++ b/src/features/analysis/index.ts @@ -3,14 +3,22 @@ * Duplicates and Search / Replace Structurally. */ import { registerActions } from "@/platform/actions"; +import { useProject } from "@/platform/project"; import { registerOverlay, registerToolWindow } from "@/workbench/contributions"; import { showDuplicatesDialog, showInspectCodeDialog, showStructuralDialog } from "./AnalysisDialogs"; import { AnalysisDialogHost } from "./dialogs"; +import { resetDuplicates } from "./duplicates"; import { DuplicatesHeaderActions, DuplicatesView } from "./DuplicatesView"; +import { resetInspection } from "./inspect"; import { InspectionHeaderActions, InspectionView } from "./InspectionView"; export function activate(): () => void { const disposers = [ + useProject.subscribe((s, prev) => { + if (s.project?.root === prev.project?.root) return; + resetInspection(); + resetDuplicates(); + }), registerOverlay({ id: "analysis.dialogs", component: AnalysisDialogHost }), registerToolWindow({ id: "inspection", diff --git a/src/features/analysis/inspect.ts b/src/features/analysis/inspect.ts index c9fcb0ef3..ccb895853 100644 --- a/src/features/analysis/inspect.ts +++ b/src/features/analysis/inspect.ts @@ -101,7 +101,7 @@ interface InspectState { last: ScopeChoice | null; } -export const useInspection = create(() => ({ +const initialInspection = (): InspectState => ({ running: false, total: 0, done: 0, @@ -110,7 +110,9 @@ export const useInspection = create(() => ({ scopeLabel: null, error: null, last: null, -})); +}); + +export const useInspection = create(initialInspection); let inflight: RequestId | null = null; @@ -119,6 +121,13 @@ export function cancelInspection() { void commands.cancelRequest(inflight).catch(() => undefined); } +/** The window switched projects: stops the run and forgets its results. */ +export function resetInspection() { + cancelInspection(); + inflight = null; + useInspection.setState(initialInspection()); +} + export async function runInspection(choice: ScopeChoice): Promise { const scope = await resolveScope(choice); if (typeof scope === "string") { diff --git a/src/features/app/closeGuard.ts b/src/features/app/closeGuard.ts index 4e23cd5fe..2f76a82f3 100644 --- a/src/features/app/closeGuard.ts +++ b/src/features/app/closeGuard.ts @@ -9,7 +9,7 @@ */ import { commands, isTauri, onIdeEvent, type Settings } from "@/platform/ipc"; import { t } from "@/platform/i18n"; -import { useProject } from "@/platform/project"; +import { provideLeaveProjectGuard, useProject } from "@/platform/project"; import { maybeEditorService, notifications } from "@/workbench/contributions"; import { ask, type Ask } from "./prompt"; @@ -115,6 +115,15 @@ async function onCloseRequested(deps: CloseDeps): Promise { } } +/** Unsaved changes before this window opens another project in place or closes it. */ +export function confirmLeave(deps: CloseDeps = defaultDeps): Promise { + const s = useProject.getState(); + return decideClose( + { quitting: false, windows: 2, confirmExit: false, dirty: dirtyCount(), project: s.project?.name ?? null }, + deps, + ); +} + /** Quit: confirm once (if enabled), then let the backend close every window. */ export async function quitApp(deps: CloseDeps = defaultDeps): Promise { if (!isTauri()) return; @@ -134,6 +143,7 @@ export function installCloseGuard(deps: CloseDeps = defaultDeps): () => void { useProject.subscribe((s, prev) => { if (s.project?.root !== prev.project?.root) dirtyDocs.clear(); }), + provideLeaveProjectGuard(() => confirmLeave(deps)), ]; let unlisten: (() => void) | undefined; let disposed = false; diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index a63553209..45568af18 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -6,7 +6,7 @@ import type { Action } from "@/platform/actions"; import { t } from "@/platform/i18n"; import { call, commands, isTauri } from "@/platform/ipc"; -import { basename, openProject, useProject } from "@/platform/project"; +import { basename, confirmLeaveProject, openProject, useProject } from "@/platform/project"; import { notifications } from "@/workbench/contributions"; import { ask } from "./prompt"; @@ -24,6 +24,7 @@ function desktopOnly(): boolean { async function openHere(path: string) { if (useProject.getState().project?.root === path) return; + if (!(await confirmLeaveProject())) return; try { await openProject(path); } catch (e) { diff --git a/src/features/code-insight/hierarchy.ts b/src/features/code-insight/hierarchy.ts index 1d8224ebe..1e64c588a 100644 --- a/src/features/code-insight/hierarchy.ts +++ b/src/features/code-insight/hierarchy.ts @@ -45,6 +45,17 @@ export const useHierarchy = create(() => ({ generation: 0, })); +/** The window switched projects: the tree refers to the previous one's documents. */ +export function resetHierarchy() { + useHierarchy.setState((s) => ({ + root: null, + children: new Map(), + failed: new Set(), + loading: false, + generation: s.generation + 1, + })); +} + export function directionLabel(mode: HierarchyMode, direction: HierarchyDirection): string { if (mode === "call") return direction === "incoming" ? "Callers" : "Callees"; return direction === "incoming" ? "Supertypes" : "Subtypes"; diff --git a/src/features/code-insight/index.ts b/src/features/code-insight/index.ts index 6b4d3202f..c82cf6d28 100644 --- a/src/features/code-insight/index.ts +++ b/src/features/code-insight/index.ts @@ -11,9 +11,9 @@ import { installProblemDecorations } from "./decorations"; import { ProblemsHeaderActions, ProblemsTabs, ProblemsView } from "./ProblemsView"; import { UsagesTabs, UsagesView } from "./UsagesView"; import { UsagesPopup } from "./UsagesPopup"; -import { findUsages, showUsageResults, showUsagesPopup } from "./usages"; +import { findUsages, resetUsages, showUsageResults, showUsagesPopup } from "./usages"; import { HierarchyTabs, HierarchyView } from "./HierarchyView"; -import { showHierarchy } from "./hierarchy"; +import { resetHierarchy, showHierarchy } from "./hierarchy"; import { installServerEvents } from "./servers"; import { ServerStatus } from "./ServerStatus"; @@ -35,6 +35,8 @@ export function activate(): () => void { if (r !== root) { root = r; clearProblems(); + resetUsages(); + resetHierarchy(); } }); const disposers = [ diff --git a/src/features/code-insight/usages.ts b/src/features/code-insight/usages.ts index 3ec72ac25..39156f26b 100644 --- a/src/features/code-insight/usages.ts +++ b/src/features/code-insight/usages.ts @@ -126,6 +126,12 @@ export async function fetchUsages(c: CaretContext): Promise { return { groups: groupUsages(locs, files), note }; } +/** The window switched projects: results (and in-flight searches) of the previous one are dropped. */ +export function resetUsages() { + useUsages.setState({ search: null }); + useUsagesPopup.setState({ open: false, symbol: null, groups: [], note: null }); +} + export async function findUsages() { const c = caretContext(); if (!c) { diff --git a/src/features/database/index.ts b/src/features/database/index.ts index 1559bd159..fff28de70 100644 --- a/src/features/database/index.ts +++ b/src/features/database/index.ts @@ -67,6 +67,9 @@ export function activate(): () => void { treeReveal: null, manualCommit: new Set(), pendingTx: new Set(), + running: {}, + dialog: null, + newMenuOpen: false, }); }), registerDatabaseTreeActions(), diff --git a/src/features/debugger/index.ts b/src/features/debugger/index.ts index 6e2cbd10f..a8dff400a 100644 --- a/src/features/debugger/index.ts +++ b/src/features/debugger/index.ts @@ -16,7 +16,7 @@ import { loadSpecialBreakpoints } from "./breakpoints/special"; import { breakpointGutter } from "./editor/breakpointGutter"; import { executionPoint } from "./editor/execution"; import { onHotCodeReplace } from "./hotswap"; -import { handleDebugEvent, loadWatches, publishContext } from "./state"; +import { handleDebugEvent, loadWatches, publishContext, resetSessions } from "./state"; import { DebuggerOverlays } from "./ui/Overlays"; export function activate(): () => void { @@ -50,6 +50,7 @@ export function activate(): () => void { useProject.subscribe((s, prev) => { const root = s.project?.root ?? null; if (root !== (prev.project?.root ?? null)) { + resetSessions(); loadBreakpoints(root); loadExceptionBreakpoints(root); loadSpecialBreakpoints(root); diff --git a/src/features/debugger/state.ts b/src/features/debugger/state.ts index d16539658..ce2b83249 100644 --- a/src/features/debugger/state.ts +++ b/src/features/debugger/state.ts @@ -635,6 +635,15 @@ export function handleDebugEvent(id: DebugSessionId, event: DebugEvent) { } } +/** The window switched projects: stops its sessions and forgets them (late events stay ignored via `owned`). */ +export function resetSessions() { + const { sessions } = useDebug.getState(); + for (const s of Object.values(sessions)) if (s.parent == null && isLive(s)) void stopSession(s.id); + useDebug.setState({ sessions: {}, active: null, console: {}, stopSeq: 0 }); + useExecutionPoint.setState({ path: null, line: 0, top: true, values: {} }); + publishContext(); +} + /** Breakpoint ↔ session glue: unverified look only makes sense while debugging. */ export function sessionsRunning(): boolean { return Object.values(useDebug.getState().sessions).some(isLive); diff --git a/src/features/github/index.ts b/src/features/github/index.ts index 09e71d360..db1caf23e 100644 --- a/src/features/github/index.ts +++ b/src/features/github/index.ts @@ -9,7 +9,7 @@ import { registerOverlay, registerToolWindow } from "@/workbench/contributions"; import { GithubDialogHost } from "./dialogs"; import { checkoutAction, createPullRequest, openInBrowser, showPullRequests, WINDOW } from "./ops"; import { PullRequestsHeader, PullRequestsView } from "./PullRequestsView"; -import { DEFAULT_QUERY, refreshAll, useGithub } from "./store"; +import { refreshAll, resetGithub } from "./store"; const WHEN = "projectOpen"; @@ -65,10 +65,8 @@ export function activate(): () => void { }, { id: "github.refresh", title: "Refresh Pull Requests", category: "GitHub", icon: "refresh", when: WHEN, run: () => refreshAll() }, ]), - // Another project: start over. useProject.subscribe((s, prev) => { - if (s.project?.root === prev.project?.root) return; - useGithub.setState({ availability: null, list: [], selected: null, details: null, reviewComments: [], query: DEFAULT_QUERY }); + if (s.project?.root !== prev.project?.root) resetGithub(); }), ]; return () => disposers.reverse().forEach((d) => d()); diff --git a/src/features/github/store.ts b/src/features/github/store.ts index b2e79df88..8b25b3de2 100644 --- a/src/features/github/store.ts +++ b/src/features/github/store.ts @@ -163,6 +163,12 @@ export function cancelBusy() { const diffCache = new Map(); +/** Another project: start over. */ +export function resetGithub() { + diffCache.clear(); + useGithub.setState({ availability: null, list: [], selected: null, details: null, reviewComments: [], query: DEFAULT_QUERY }); +} + /** Fetches what the local repository lacks to diff `pr`, once per head commit. */ export async function diffRevisions(pr: PullRequestDetails): Promise { const key = `${pr.summary.number}:${pr.headOid}:${pr.baseOid}`; diff --git a/src/features/http-client/index.ts b/src/features/http-client/index.ts index 609692242..334f55339 100644 --- a/src/features/http-client/index.ts +++ b/src/features/http-client/index.ts @@ -7,11 +7,12 @@ import { activeEditor, registerEditorContribution, registerLanguageMode } from "@/editor/api"; import { registerActions } from "@/platform/actions"; import { call, commands, emitLocalIdeEvent, IpcError, isTauri } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; import { editorService, notifications, registerServiceView, showServiceView } from "@/workbench/contributions"; import { HTTP_LANGUAGE, httpEditorExtension, refreshRequests, requestAtCaret } from "./editor"; import { HttpClientHeaderActions, HttpClientView } from "./HttpClientView"; import { httpLanguageDescription } from "./language"; -import { SERVICE_VIEW, runAll, runRequest } from "./store"; +import { SERVICE_VIEW, resetHttpClient, runAll, runRequest } from "./store"; export const NEW_REQUEST_TEMPLATE = `### Example request # Variables come from http-client.env.json / http-client.private.env.json @@ -73,6 +74,9 @@ async function newRequest() { export function activate(): () => void { const disposers = [ + useProject.subscribe((s, prev) => { + if (s.project?.root !== prev.project?.root) resetHttpClient(); + }), registerLanguageMode(httpLanguageDescription), registerEditorContribution({ id: "http.requests", order: 56, extensions: httpEditorExtension }), registerServiceView({ diff --git a/src/features/http-client/store.ts b/src/features/http-client/store.ts index 9822c9231..16afd399b 100644 --- a/src/features/http-client/store.ts +++ b/src/features/http-client/store.ts @@ -166,3 +166,9 @@ export function cancelRunning() { const r = useHttpClient.getState().running; if (r) void commands.cancelRequest(r.request); } + +/** The window switched projects: history, responses and environments belong to the previous one. */ +export function resetHttpClient() { + cancelRunning(); + useHttpClient.setState({ environments: {}, history: [], exchanges: {}, selected: null, running: null }); +} diff --git a/src/features/run/index.ts b/src/features/run/index.ts index ebd0646fa..a8e4399d0 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -33,7 +33,7 @@ import { registerExternalTools } from "./externalTools"; import { EditConfigsOverlay } from "./EditConfigsDialog"; import { RunWidget } from "./RunWidget"; import { runGutter } from "./runGutter"; -import { activateStackTrace } from "./stackTrace"; +import { activateStackTrace, useStackTraces } from "./stackTrace"; import { clearRuntimeCache } from "./runtimes"; import { adoptTemporary, @@ -373,6 +373,7 @@ export function activate(): () => void { useProject.subscribe((s, prev) => { if (s.project?.root === prev.project?.root) return; resetRuns(); + useStackTraces.setState({ tabs: [], dialog: null }); if (s.project) void loadConfigs(); }), ]; diff --git a/src/features/search/common/tracking.ts b/src/features/search/common/tracking.ts index 98e94dee0..fb574f4b7 100644 --- a/src/features/search/common/tracking.ts +++ b/src/features/search/common/tracking.ts @@ -36,6 +36,11 @@ export const useTracking = create(() => ({ const docPaths = new Map(); +/** The window switched projects: the previous one's places and edits don't apply. */ +export function resetTracking() { + useTracking.setState({ caret: null, editVersions: new Map(), edited: new Set(), locations: [] }); +} + function bumpVersion(path: string, userEdit: boolean) { useTracking.setState((s) => ({ editVersions: new Map(s.editVersions).set(path, (s.editVersions.get(path) ?? 0) + 1), diff --git a/src/features/search/index.ts b/src/features/search/index.ts index f904c04cc..02264412d 100644 --- a/src/features/search/index.ts +++ b/src/features/search/index.ts @@ -20,11 +20,11 @@ import { } from "./bookmarks/store"; import { maybeToolWindowService } from "./common/services"; import { openLocation } from "./common/open"; -import { installTracking } from "./common/tracking"; +import { installTracking, resetTracking } from "./common/tracking"; import { SearchEverywhereOverlay } from "./everywhere/SearchEverywhere"; import { hideSearchEverywhere, showSearchEverywhere } from "./everywhere/store"; import { FindInFilesOverlay } from "./find/FindInFiles"; -import { hideFindPopup, parseFindArg, showFindPopup } from "./find/store"; +import { hideFindPopup, parseFindArg, showFindPopup, useFindPopup } from "./find/store"; import { disposeFindWindow } from "./find/windowStore"; import { RecentPopupsOverlay } from "./recent/RecentPopups"; import { cycleSwitcher, hideRecentPopup, showRecentPopup } from "./recent/store"; @@ -294,7 +294,11 @@ export function activate(): void | (() => void) { onProject(useProject.getState().project?.root ?? null); disposers.push( useProject.subscribe((s, prev) => { - if (s.project?.root !== prev.project?.root) onProject(s.project?.root ?? null); + if (s.project?.root === prev.project?.root) return; + onProject(s.project?.root ?? null); + resetTracking(); + // A directory scope pointed into the previous project. + if (useFindPopup.getState().scopeTab === "directory") useFindPopup.setState({ scopeTab: "project", directory: "" }); }), onIdeEvent("fsChanged", (e) => { if (!e.removed.length) return; diff --git a/src/features/vcs/__tests__/projectSwitch.test.ts b/src/features/vcs/__tests__/projectSwitch.test.ts new file mode 100644 index 000000000..c6fe78458 --- /dev/null +++ b/src/features/vcs/__tests__/projectSwitch.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { installPreparedMessage, useCommit } from "../commitStore"; +import { installGitLog, useGitLog } from "../logStore"; +import { logConsole, useVcsConsole } from "../ops"; +import { useVcs } from "../store"; + +describe("switching the window to another repository", () => { + const disposers: Array<() => void> = []; + afterEach(() => disposers.splice(0).forEach((d) => d())); + + it("drops the commit message, Amend and the log of the previous one", () => { + useVcs.setState({ root: "/a" }); + disposers.push(installPreparedMessage(), installGitLog()); + useCommit.setState({ message: "wip", amend: true, selection: ["/a/x"] }); + useGitLog.setState({ commits: [{ id: "c1" } as never], selected: "c1", selection: ["c1"] }); + useGitLog.setState((s) => ({ filters: { ...s.filters, author: "me" } })); + logConsole("cmd", "git status"); + + useVcs.setState({ root: "/b" }); + + expect(useCommit.getState()).toMatchObject({ message: "", amend: false, selection: [] }); + expect(useGitLog.getState()).toMatchObject({ commits: [], selected: null, selection: [] }); + expect(useGitLog.getState().filters.author).toBe(""); + expect(useVcsConsole.getState().lines).toEqual([]); + }); +}); diff --git a/src/features/vcs/commitStore.ts b/src/features/vcs/commitStore.ts index 9145fc2b5..41172305b 100644 --- a/src/features/vcs/commitStore.ts +++ b/src/features/vcs/commitStore.ts @@ -109,6 +109,20 @@ export function installPreparedMessage(): () => void { const op = (s = useVcs.getState()) => s.branch?.operation ?? null; if (PREPARED_MESSAGE_OPS.has(op() ?? "")) void load(); return useVcs.subscribe((s, prev) => { + if (s.root !== prev.root) { + // Another repository: nothing typed or ticked for the previous one carries over (Amend least of all). + useCommit.setState({ + message: "", + amend: false, + checks: new Map(), + selection: [], + tab: "changes", + selectedList: null, + selectedShelf: null, + }); + if (PREPARED_MESSAGE_OPS.has(op(s) ?? "")) void load(); + return; + } const now = op(s); if (now !== op(prev) && PREPARED_MESSAGE_OPS.has(now ?? "")) void load(); }); diff --git a/src/features/vcs/logStore.ts b/src/features/vcs/logStore.ts index beb1f752c..eaaab18cb 100644 --- a/src/features/vcs/logStore.ts +++ b/src/features/vcs/logStore.ts @@ -4,7 +4,7 @@ import type { Branch, BranchInfo, Commit } from "@/bindings"; import { call, commands } from "@/platform/ipc"; import { workflowSettings } from "@/platform/workflowSettings"; import { computeGraph, emptyGraphState, type GraphRow, type GraphState } from "./graph"; -import { errorMessage } from "./ops"; +import { clearConsole, errorMessage } from "./ops"; import { useVcs } from "./store"; export interface LogFilters { @@ -71,12 +71,13 @@ export const useGitLog = create(() => ({ ...initialLog(), generation: let loadPromise: Promise | null = null; -/** Another repository (the window switched projects): forget its commits, filters and selection. */ +/** Another repository (the window switched projects): forget its commits, filters, selection and console. */ export function installGitLog(): () => void { return useVcs.subscribe((s, prev) => { if (s.root === prev.root) return; loadPromise = null; useGitLog.setState((l) => ({ ...initialLog(), tab: l.tab, generation: l.generation + 1 })); + clearConsole(); }); } diff --git a/src/platform/project.ts b/src/platform/project.ts index 5ba1761e1..f23c11fd9 100644 --- a/src/platform/project.ts +++ b/src/platform/project.ts @@ -12,6 +12,21 @@ interface ProjectState { export const useProject = create(() => ({ project: null, settings: null, opening: false })); +let leaveGuard: (() => Promise) | null = null; + +/** Installs the check run before this window leaves its project (unsaved changes). */ +export function provideLeaveProjectGuard(guard: () => Promise): () => void { + leaveGuard = guard; + return () => { + if (leaveGuard === guard) leaveGuard = null; + }; +} + +/** False when the user chose to stay (another project in place, Close Project). */ +export function confirmLeaveProject(): Promise { + return useProject.getState().project && leaveGuard ? leaveGuard() : Promise.resolve(true); +} + export async function openProject(path: string): Promise { useProject.setState({ opening: true }); try { diff --git a/src/workbench/activate.ts b/src/workbench/activate.ts index 7bfcdcb43..d030e8c84 100644 --- a/src/workbench/activate.ts +++ b/src/workbench/activate.ts @@ -13,7 +13,7 @@ import { import { installBrowserGuards } from "./browserGuards"; import { installDistractionFree } from "./distractionFree"; import { installFocusTracking } from "./focus"; -import { installNotifyEvents, notificationServiceImpl } from "./notificationStore"; +import { clearNotifications, installNotifyEvents, notificationServiceImpl } from "./notificationStore"; import { NOTIFICATIONS_TOOL_WINDOW, NotificationsHeaderActions, @@ -39,7 +39,10 @@ export function activateWorkbench(): () => void { }; onProject(useProject.getState().project?.root ?? null); const unsubProject = useProject.subscribe((s, prev) => { - if (s.project?.root !== prev.project?.root) onProject(s.project?.root ?? null); + if (s.project?.root === prev.project?.root) return; + // The previous project's notifications (and their actions) no longer apply. + if (prev.project) clearNotifications(); + onProject(s.project?.root ?? null); }); const disposers = [ diff --git a/src/workbench/shellActions.ts b/src/workbench/shellActions.ts index e00297e66..677a8bee9 100644 --- a/src/workbench/shellActions.ts +++ b/src/workbench/shellActions.ts @@ -1,6 +1,6 @@ import { executeAction, getAction, registerActions } from "@/platform/actions"; import { isTauri } from "@/platform/ipc"; -import { closeProject, openProject, useProject } from "@/platform/project"; +import { closeProject, confirmLeaveProject, openProject, useProject } from "@/platform/project"; import { notify } from "./notificationStore"; import { NOTIFICATIONS_TOOL_WINDOW } from "./Notifications"; import { activateToolWindow } from "./toolwindows/store"; @@ -49,7 +49,7 @@ export function registerShellActions(): () => void { category: "File", when: "projectOpen", run: async () => { - if (useProject.getState().project) await closeProject(); + if (useProject.getState().project && (await confirmLeaveProject())) await closeProject(); }, }, {