diff --git a/crates/ide-index/src/worker.rs b/crates/ide-index/src/worker.rs index 4acc370d..1e25d4b5 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 0c7723cb..34ed04af 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 e350b8d1..6418f3aa 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 12cfde1f..dec6394f 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 e32e6a55..4cb22e7e 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 2716cb17..b843e884 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 78061d98..66619114 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 9e102968..a70ce1eb 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 c9fcb0ef..ccb89585 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 4e23cd5f..2f76a82f 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 a6355320..45568af1 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 1d8224eb..1e64c588 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 6b4d3202..c82cf6d2 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 3ec72ac2..39156f26 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 1559bd15..fff28de7 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 6e2cbd10..a8dff400 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 d1653965..ce2b8324 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 09e71d36..db1caf23 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 b2e79df8..8b25b3de 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 60969224..334f5533 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 9822c923..16afd399 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 ebd0646f..a8e4399d 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 98e94dee..fb574f4b 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 f904c04c..02264412 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 00000000..c6fe7845 --- /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 9145fc2b..41172305 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 beb1f752..eaaab18c 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 5ba1761e..f23c11fd 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 7bfcdcb4..d030e8c8 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 e00297e6..677a8bee 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(); }, }, {