Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ pub fn run() {
settings::update_app_settings,
settings::get_codex_config_path,
menu::menu_set_accelerators,
menu::app_quit,
menu::ack_menu_quit,
codex::codex_doctor,
workspaces::list_workspaces,
workspaces::is_workspace_path_dir,
Expand Down
59 changes: 58 additions & 1 deletion src-tauri/src/menu.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::Ordering;

use serde::Deserialize;
use tauri::menu::{Menu, MenuItem, MenuItemBuilder, PredefinedMenuItem, Submenu};
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder};
use tauri::{AppHandle, Emitter, Manager, Runtime, WebviewUrl, WebviewWindowBuilder};
use tokio::time::{Duration, sleep};

use crate::state::AppState;

const MENU_QUIT_FALLBACK_MS: u64 = 2000;

pub struct MenuItemRegistry<R: Runtime> {
items: Mutex<HashMap<String, MenuItem<R>>>,
Expand Down Expand Up @@ -58,6 +64,17 @@ pub fn menu_set_accelerators<R: Runtime>(
Ok(())
}

#[tauri::command]
pub fn app_quit(app: AppHandle) {
app.exit(0);
}

#[tauri::command]
pub fn ack_menu_quit(app: AppHandle) {
let state = app.state::<AppState>();
state.menu_quit_ack.store(true, Ordering::SeqCst);
}

pub(crate) fn build_menu<R: tauri::Runtime>(
handle: &tauri::AppHandle<R>,
) -> tauri::Result<Menu<R>> {
Expand All @@ -70,6 +87,10 @@ pub(crate) fn build_menu<R: tauri::Runtime>(
let settings_item = MenuItemBuilder::with_id("file_open_settings", "Settings...")
.accelerator("CmdOrCtrl+,")
.build(handle)?;
#[cfg(target_os = "macos")]
let app_quit_item = MenuItemBuilder::with_id("app_quit", format!("Quit {app_name}"))
.accelerator("CmdOrCtrl+Q")
.build(handle)?;
let app_menu = Submenu::with_items(
handle,
app_name.clone(),
Expand All @@ -84,6 +105,9 @@ pub(crate) fn build_menu<R: tauri::Runtime>(
&PredefinedMenuItem::hide(handle, None)?,
&PredefinedMenuItem::hide_others(handle, None)?,
&PredefinedMenuItem::separator(handle)?,
#[cfg(target_os = "macos")]
&app_quit_item,
#[cfg(not(target_os = "macos"))]
&PredefinedMenuItem::quit(handle, None)?,
],
)?;
Expand Down Expand Up @@ -359,6 +383,31 @@ pub(crate) fn handle_menu_event<R: tauri::Runtime>(
"file_quit" => {
app.exit(0);
}
"app_quit" => {
if should_hold_to_quit(app) {
let state = app.state::<AppState>();
state.menu_quit_ack.store(false, Ordering::SeqCst);
if let Some(window) = app.get_webview_window("main") {
if window.emit("menu-quit", ()).is_err() {
app.exit(0);
return;
}
} else {
app.exit(0);
return;
}
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
sleep(Duration::from_millis(MENU_QUIT_FALLBACK_MS)).await;
let state = app_handle.state::<AppState>();
if !state.menu_quit_ack.load(Ordering::SeqCst) {
app_handle.exit(0);
}
});
} else {
app.exit(0);
Comment on lines +386 to +408
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ensure quit still works if renderer isn't listening

When hold-to-quit is enabled, the macOS quit menu path no longer calls app.exit(0) and instead only emits menu-quit to the main window. If the renderer isn’t ready (early startup) or has crashed/hung, no listener will ever call requestAppQuit, so the Quit menu/accelerator becomes a no-op and the app cannot exit normally. Consider adding a fallback exit when no window exists or after a short timeout if the event isn’t handled.

Useful? React with 👍 / 👎.

}
}
"view_fullscreen" => {
if let Some(window) = app.get_webview_window("main") {
let is_fullscreen = window.is_fullscreen().unwrap_or(false);
Expand Down Expand Up @@ -400,3 +449,11 @@ fn emit_menu_event<R: tauri::Runtime>(app: &tauri::AppHandle<R>, event: &str) {
let _ = app.emit(event, ());
}
}

fn should_hold_to_quit<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> bool {
let state = app.state::<AppState>();
tauri::async_runtime::block_on(async {
let settings = state.app_settings.lock().await;
settings.experimental_hold_to_quit_enabled
})
}
3 changes: 3 additions & 0 deletions src-tauri/src/state.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;

use tauri::{AppHandle, Manager};
use tokio::sync::Mutex;
Expand All @@ -19,6 +20,7 @@ pub(crate) struct AppState {
pub(crate) settings_path: PathBuf,
pub(crate) app_settings: Mutex<AppSettings>,
pub(crate) dictation: Mutex<DictationState>,
pub(crate) menu_quit_ack: AtomicBool,
}

impl AppState {
Expand All @@ -40,6 +42,7 @@ impl AppState {
settings_path,
app_settings: Mutex::new(app_settings),
dictation: Mutex::new(DictationState::default()),
menu_quit_ack: AtomicBool::new(false),
}
}
}
11 changes: 11 additions & 0 deletions src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,11 @@ pub(crate) struct AppSettings {
rename = "experimentalUnifiedExecEnabled"
)]
pub(crate) experimental_unified_exec_enabled: bool,
#[serde(
default = "default_experimental_hold_to_quit_enabled",
rename = "experimentalHoldToQuitEnabled"
)]
pub(crate) experimental_hold_to_quit_enabled: bool,
#[serde(default = "default_dictation_enabled", rename = "dictationEnabled")]
pub(crate) dictation_enabled: bool,
#[serde(
Expand Down Expand Up @@ -567,6 +572,10 @@ fn default_experimental_unified_exec_enabled() -> bool {
false
}

fn default_experimental_hold_to_quit_enabled() -> bool {
false
}

fn default_dictation_enabled() -> bool {
false
}
Expand Down Expand Up @@ -711,6 +720,7 @@ impl Default for AppSettings {
experimental_collaboration_modes_enabled: false,
experimental_steer_enabled: false,
experimental_unified_exec_enabled: false,
experimental_hold_to_quit_enabled: false,
dictation_enabled: false,
dictation_model_id: default_dictation_model_id(),
dictation_preferred_language: None,
Expand Down Expand Up @@ -794,6 +804,7 @@ mod tests {
assert_eq!(settings.code_font_size, 11);
assert!(settings.notification_sounds_enabled);
assert!(!settings.experimental_steer_enabled);
assert!(!settings.experimental_hold_to_quit_enabled);
assert!(!settings.dictation_enabled);
assert_eq!(settings.dictation_model_id, "base");
assert!(settings.dictation_preferred_language.is_none());
Expand Down
10 changes: 10 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import "./styles/messages.css";
import "./styles/approval-toasts.css";
import "./styles/request-user-input.css";
import "./styles/update-toasts.css";
import "./styles/quit-hold.css";
import "./styles/composer.css";
import "./styles/diff.css";
import "./styles/diff-viewer.css";
Expand All @@ -31,6 +32,7 @@ import errorSoundUrl from "./assets/error-notification.mp3";
import { AppLayout } from "./features/app/components/AppLayout";
import { AppModals } from "./features/app/components/AppModals";
import { MainHeaderActions } from "./features/app/components/MainHeaderActions";
import { QuitHoldIndicator } from "./features/app/components/QuitHoldIndicator";
import { useLayoutNodes } from "./features/layout/hooks/useLayoutNodes";
import { useWorkspaceDropZone } from "./features/workspaces/hooks/useWorkspaceDropZone";
import { useThreads } from "./features/threads/hooks/useThreads";
Expand Down Expand Up @@ -78,6 +80,7 @@ import { useSettingsModalState } from "./features/app/hooks/useSettingsModalStat
import { usePersistComposerSettings } from "./features/app/hooks/usePersistComposerSettings";
import { useSyncSelectedDiffPath } from "./features/app/hooks/useSyncSelectedDiffPath";
import { useMenuAcceleratorController } from "./features/app/hooks/useMenuAcceleratorController";
import { useHoldToQuit } from "./features/app/hooks/useHoldToQuit";
import { useAppMenuEvents } from "./features/app/hooks/useAppMenuEvents";
import { useWorkspaceActions } from "./features/app/hooks/useWorkspaceActions";
import { useWorkspaceCycling } from "./features/app/hooks/useWorkspaceCycling";
Expand Down Expand Up @@ -1379,6 +1382,9 @@ function MainApp() {
});

useMenuAcceleratorController({ appSettings, onDebug: addDebugEntry });
const { state: quitHoldState } = useHoldToQuit({
enabled: appSettings.experimentalHoldToQuitEnabled,
});

const isDefaultScale = Math.abs(uiScale - 1) < 0.001;
const dropOverlayActive = isWorkspaceDropActive;
Expand Down Expand Up @@ -1852,6 +1858,10 @@ function MainApp() {
/>
</Suspense>
) : null}
<QuitHoldIndicator
isActive={quitHoldState.status === "holding"}
progress={quitHoldState.progress}
/>
<AppLayout
isPhone={isPhone}
isTablet={isTablet}
Expand Down
30 changes: 30 additions & 0 deletions src/features/app/components/QuitHoldIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
type QuitHoldIndicatorProps = {
isActive: boolean;
progress: number;
};

export function QuitHoldIndicator({
isActive,
progress,
}: QuitHoldIndicatorProps) {
if (!isActive) {
return null;
}

const percent = Math.min(100, Math.max(0, progress * 100));

return (
<div
className="quit-hold-indicator"
role="progressbar"
aria-label="Hold to quit"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
>
<span className="quit-hold-track">
<span className="quit-hold-fill" style={{ width: `${percent}%` }} />
</span>
</div>
);
}
36 changes: 36 additions & 0 deletions src/features/app/components/QuitHoldToast.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { QuitHoldState } from "../hooks/useHoldToQuit";

type QuitHoldToastProps = {
state: QuitHoldState;
};

export function QuitHoldToast({ state }: QuitHoldToastProps) {
if (!state.isVisible) {
return null;
}

const isCanceled = state.status === "canceled";
const message = isCanceled ? "Quit canceled" : "Hold Cmd+Q to quit";
const progressPercent = Math.round(state.progress * 100);

return (
<div className="quit-hold-toasts" role="region" aria-live="polite">
<div
className={`quit-hold-toast ${state.status}`}
role="status"
>
<div className="quit-hold-toast-body">{message}</div>
{!isCanceled && (
<div className="quit-hold-toast-progress">
<div className="quit-hold-toast-progress-bar">
<span
className="quit-hold-toast-progress-fill"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
)}
</div>
</div>
);
}
78 changes: 78 additions & 0 deletions src/features/app/hooks/useHoldToQuit.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { requestAppQuit } from "../../../services/tauri";
import { useHoldToQuit } from "./useHoldToQuit";

vi.mock("../../../services/tauri", () => ({
ackMenuQuit: vi.fn(),
requestAppQuit: vi.fn(),
}));
vi.mock("../../../services/events", () => ({
subscribeMenuQuit: () => () => {},
}));

describe("useHoldToQuit", () => {
const originalPlatform = navigator.platform;

beforeEach(() => {
vi.useFakeTimers();
Object.defineProperty(navigator, "platform", {
value: "MacIntel",
configurable: true,
});
});

afterEach(() => {
vi.useRealTimers();
Object.defineProperty(navigator, "platform", {
value: originalPlatform,
configurable: true,
});
vi.clearAllMocks();
});

it("cancels hold and hides toast on key release", () => {
const { result } = renderHook(() => useHoldToQuit({ enabled: true }));

act(() => {
window.dispatchEvent(
new KeyboardEvent("keydown", { key: "q", metaKey: true }),
);
});

expect(result.current.state.status).toBe("holding");
expect(result.current.state.isVisible).toBe(true);

act(() => {
window.dispatchEvent(new KeyboardEvent("keyup", { key: "q" }));
});

expect(result.current.state.status).toBe("canceled");

act(() => {
vi.advanceTimersByTime(1000);
});

expect(result.current.state.isVisible).toBe(false);
});

it("requests quit after holding long enough", async () => {
const requestMock = vi.mocked(requestAppQuit);
requestMock.mockResolvedValue();

renderHook(() => useHoldToQuit({ enabled: true }));

act(() => {
window.dispatchEvent(
new KeyboardEvent("keydown", { key: "q", metaKey: true }),
);
});

act(() => {
vi.advanceTimersByTime(1600);
});

expect(requestMock).toHaveBeenCalledTimes(1);
});
});
Loading
Loading