fix(pro): prompt to save or discard draft when closing compose tab via tab-bar X

This commit is contained in:
Kristofer Pettijohn
2026-06-30 06:51:24 +02:00
committed by Linus Rath
parent 16daf6ea03
commit 2a41e73bf9
4 changed files with 74 additions and 3 deletions
+2 -2
View File
@@ -139,7 +139,7 @@ export default function ProHome() {
const focusedPaneId = useProTabStore((s) => s.focusedPaneId); const focusedPaneId = useProTabStore((s) => s.focusedPaneId);
const loadedTabIds = useProTabStore((s) => s.loadedTabIds); const loadedTabIds = useProTabStore((s) => s.loadedTabIds);
const openTab = useProTabStore((s) => s.openTab); const openTab = useProTabStore((s) => s.openTab);
const closeTab = useProTabStore((s) => s.closeTab); const requestCloseTab = useProTabStore((s) => s.requestCloseTab);
const setActiveTab = useProTabStore((s) => s.setActiveTab); const setActiveTab = useProTabStore((s) => s.setActiveTab);
const setFocusedPane = useProTabStore((s) => s.setFocusedPane); const setFocusedPane = useProTabStore((s) => s.setFocusedPane);
const moveTabToPane = useProTabStore((s) => s.moveTabToPane); const moveTabToPane = useProTabStore((s) => s.moveTabToPane);
@@ -354,7 +354,7 @@ export default function ProHome() {
activeMainTabId={activeMainTabId} activeMainTabId={activeMainTabId}
activeSplitTabId={activeSplitTabId} activeSplitTabId={activeSplitTabId}
onActivate={setActiveTab} onActivate={setActiveTab}
onClose={closeTab} onClose={requestCloseTab}
onDragStateChange={setIsTabDragging} onDragStateChange={setIsTabDragging}
/> />
+19
View File
@@ -133,6 +133,13 @@ interface EmailComposerProps {
}) => void | Promise<void>; }) => void | Promise<void>;
onScheduledSendCreated?: () => void | Promise<void>; onScheduledSendCreated?: () => void | Promise<void>;
onClose?: () => void; onClose?: () => void;
/**
* When provided, the composer assigns its close handler to `current`. The
* handler shows the unsaved-changes dialog when the draft is dirty, so a
* host (e.g. the Pro tab bar's close button) can route an external close
* request through the same guard instead of discarding silently.
*/
requestCloseRef?: React.MutableRefObject<(() => void) | null>;
onDiscardDraft?: (draftId: string) => void; onDiscardDraft?: (draftId: string) => void;
onSaveState?: (data: ComposerDraftData) => void; onSaveState?: (data: ComposerDraftData) => void;
className?: string; className?: string;
@@ -229,6 +236,7 @@ export function EmailComposer({
onSend, onSend,
onScheduledSendCreated, onScheduledSendCreated,
onClose, onClose,
requestCloseRef,
onDiscardDraft, onDiscardDraft,
onSaveState, onSaveState,
className, className,
@@ -1815,6 +1823,17 @@ export function EmailComposer({
} }
}; };
// Expose the dirty-aware close handler so external hosts (e.g. the Pro tab
// bar) can trigger the same "Save or discard draft?" guard. Re-assigned on
// every render to capture the latest closure over the live refs/state.
useEffect(() => {
if (!requestCloseRef) return;
requestCloseRef.current = handleClose;
return () => {
requestCloseRef.current = null;
};
});
const handleComposerKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { const handleComposerKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.defaultPrevented) return; if (e.defaultPrevented) return;
+20 -1
View File
@@ -7,7 +7,7 @@ import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store"; import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store"; import { toast } from "@/stores/toast-store";
import { useProTabStore, type ProComposeTabData } from "@/stores/pro-tab-store"; import { useProTabStore, registerProTabCloseInterceptor, type ProComposeTabData } from "@/stores/pro-tab-store";
import { debug } from "@/lib/debug"; import { debug } from "@/lib/debug";
interface ProComposeTabBodyProps { interface ProComposeTabBodyProps {
@@ -38,6 +38,10 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
const tabIdRef = useRef(tabId); const tabIdRef = useRef(tabId);
tabIdRef.current = tabId; tabIdRef.current = tabId;
// Set by the composer to its dirty-aware close handler. Lets the Pro tab
// bar's "X" route through the same "Save or discard draft?" guard.
const requestCloseRef = useRef<(() => void) | null>(null);
const handleScheduledSendCreated = useCallback(async () => { const handleScheduledSendCreated = useCallback(async () => {
if (client) { if (client) {
await refreshScheduledMetadata(client); await refreshScheduledMetadata(client);
@@ -120,6 +124,20 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
closeTab(tabIdRef.current); closeTab(tabIdRef.current);
}, [closeTab]); }, [closeTab]);
// Register a close interceptor so closing the tab from the tab bar (the
// "X" button or middle-click) goes through the composer's unsaved-changes
// guard, mirroring the non-Pro inline composer.
useEffect(() => {
const id = tabIdRef.current;
return registerProTabCloseInterceptor(id, () => {
if (requestCloseRef.current) {
requestCloseRef.current();
} else {
closeTab(id);
}
});
}, [closeTab]);
const handleDiscardDraft = useCallback(async (draftId: string) => { const handleDiscardDraft = useCallback(async (draftId: string) => {
if (!client) return; if (!client) return;
try { try {
@@ -160,6 +178,7 @@ export function ProComposeTabBody({ tabId, data }: ProComposeTabBodyProps) {
onSend={handleSend} onSend={handleSend}
onScheduledSendCreated={handleScheduledSendCreated} onScheduledSendCreated={handleScheduledSendCreated}
onClose={handleClose} onClose={handleClose}
requestCloseRef={requestCloseRef}
onDiscardDraft={handleDiscardDraft} onDiscardDraft={handleDiscardDraft}
onSaveState={handleSaveState} onSaveState={handleSaveState}
className="flex-1" className="flex-1"
+33
View File
@@ -87,6 +87,12 @@ interface ProTabState {
openComposeTab: (data: ProComposeTabData) => string; openComposeTab: (data: ProComposeTabData) => string;
openEmailTab: (data: ProEmailTabData) => string; openEmailTab: (data: ProEmailTabData) => string;
closeTab: (id: string) => void; closeTab: (id: string) => void;
/**
* Request closing a tab, honouring any registered close interceptor (e.g. a
* compose tab with unsaved changes that wants to show the "Save or discard
* draft?" dialog first). Falls back to `closeTab` when none is registered.
*/
requestCloseTab: (id: string) => void;
setActiveTab: (id: string) => void; setActiveTab: (id: string) => void;
setFocusedPane: (paneId: ProPaneId) => void; setFocusedPane: (paneId: ProPaneId) => void;
@@ -131,6 +137,21 @@ const HOME_TAB: ProTab = {
paneId: 'main', paneId: 'main',
}; };
/**
* Module-level registry of tab close interceptors. Kept outside the persisted
* Zustand state so functions are never serialised. A compose tab registers a
* handler here so an external close request (tab-bar "X", middle-click) routes
* through the composer's unsaved-changes guard instead of closing instantly.
*/
const closeInterceptors = new Map<string, () => void>();
export function registerProTabCloseInterceptor(id: string, fn: () => void): () => void {
closeInterceptors.set(id, fn);
return () => {
if (closeInterceptors.get(id) === fn) closeInterceptors.delete(id);
};
}
function makeId(): string { function makeId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID(); return crypto.randomUUID();
@@ -265,11 +286,23 @@ export const useProTabStore = create<ProTabState>()(
return newTab.id; return newTab.id;
}, },
requestCloseTab: (id) => {
const interceptor = closeInterceptors.get(id);
if (interceptor) {
interceptor();
return;
}
get().closeTab(id);
},
closeTab: (id) => { closeTab: (id) => {
const state = get(); const state = get();
const tab = state.tabs.find((t) => t.id === id); const tab = state.tabs.find((t) => t.id === id);
if (!tab || !tab.closeable) return; if (!tab || !tab.closeable) return;
// Drop any registered close interceptor for this tab.
closeInterceptors.delete(id);
const removedPane = tab.paneId; const removedPane = tab.paneId;
const newTabs = state.tabs.filter((t) => t.id !== id); const newTabs = state.tabs.filter((t) => t.id !== id);
const newLoaded = state.loadedTabIds.filter((tid) => tid !== id); const newLoaded = state.loadedTabIds.filter((tid) => tid !== id);