diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa8777397d..75adc88f1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,8 @@ jobs: ~/.cargo/git/db/ target/ key: test-cargo-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }} + - name: Install wasm-tools + run: cargo install wasm-tools --locked --version 1.251.0 - name: Test Linux if: ${{ matrix.run_on == 'ubuntu-latest' }} run: | diff --git a/Cargo.lock b/Cargo.lock index fcd228a97c..837fa6bf87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6777,7 +6777,7 @@ checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" [[package]] name = "main" -version = "0.8.3" +version = "0.8.4" dependencies = [ "agent_runtime", "ai_chat_view", diff --git a/crates/connection-import-protocol/src/model.rs b/crates/connection-import-protocol/src/model.rs index ff2aed054c..438a99ac17 100644 --- a/crates/connection-import-protocol/src/model.rs +++ b/crates/connection-import-protocol/src/model.rs @@ -35,6 +35,8 @@ pub struct ImporterCapabilities { pub supports_scan: bool, pub supports_password_import: bool, pub supports_manual_file_pick: bool, + #[serde(default)] + pub manual_file_pick_prompt: Option, pub supports_incremental_preview: bool, } diff --git a/crates/core/src/split_tab_container.rs b/crates/core/src/split_tab_container.rs index a81f0c78ea..2f766de706 100644 --- a/crates/core/src/split_tab_container.rs +++ b/crates/core/src/split_tab_container.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use gpui::{ AnyElement, App, AppContext as _, Axis, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement as _, IntoElement, ParentElement, Render, SharedString, Styled, - Subscription, Window, div, + Subscription, Task, Window, div, }; use gpui_component::{ Placement, @@ -49,6 +49,23 @@ impl SplitNode { } } + fn collect_close_panes(&self) -> Vec> { + let mut panes = Vec::new(); + self.collect_close_panes_into(&mut panes); + panes + } + + fn collect_close_panes_into(&self, panes: &mut Vec>) { + match self { + Self::Leaf(pane) => panes.push(pane.clone()), + Self::Split { children, .. } => { + for child in children { + child.collect_close_panes_into(panes); + } + } + } + } + fn insert_split( &mut self, target: &Entity, @@ -135,6 +152,31 @@ impl SplitTabContainer { self.active_pane.clone() } + pub fn close_all_tabs(&mut self, _window: &mut Window, cx: &mut Context) -> Task { + let panes = self.root.collect_close_panes(); + let Some(window_id) = cx.active_window() else { + return Task::ready(false); + }; + + cx.spawn(async move |_handle, cx| { + for pane in panes { + let close_task = cx.update_window(window_id, |_, window, cx| { + pane.update(cx, |pane, cx| pane.close_all_tabs(window, cx)) + }); + + match close_task { + Ok(task) => { + if !task.await { + return false; + } + } + Err(_) => return false, + } + } + true + }) + } + fn create_secondary_pane( &self, window: &mut Window, @@ -360,3 +402,52 @@ impl Render for SplitTabContainer { .child(content) } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{TestAppContext, WindowOptions}; + use gpui_component::Theme; + + #[gpui::test] + fn split_close_collects_leaf_panes_in_tree_order(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.set_global(Theme::default()); + let window = cx + .open_window(WindowOptions::default(), |window, cx| { + let primary = cx.new(|cx| TabContainer::new(window, cx)); + let right_top = cx.new(|cx| TabContainer::new(window, cx)); + let right_bottom = cx.new(|cx| TabContainer::new(window, cx)); + let tree = SplitNode::Split { + axis: Axis::Horizontal, + children: vec![ + SplitNode::Leaf(primary.clone()), + SplitNode::Split { + axis: Axis::Vertical, + children: vec![ + SplitNode::Leaf(right_top.clone()), + SplitNode::Leaf(right_bottom.clone()), + ], + }, + ], + }; + + let panes = tree.collect_close_panes(); + + assert_eq!(vec![primary, right_top, right_bottom], panes); + panes[0].clone() + }) + .expect("window opens"); + drop(window); + }); + } + + #[test] + fn split_container_exposes_close_all_tabs_contract() { + let _close_all_tabs: fn( + &mut SplitTabContainer, + &mut Window, + &mut Context, + ) -> gpui::Task = SplitTabContainer::close_all_tabs; + } +} diff --git a/crates/core/src/tab_container.rs b/crates/core/src/tab_container.rs index 007aa00ced..e5e5763662 100644 --- a/crates/core/src/tab_container.rs +++ b/crates/core/src/tab_container.rs @@ -725,6 +725,8 @@ pub struct TabContainer { on_toggle_always_on_top: Option>, /// 当前窗口置顶状态读取器,由上层注入 is_always_on_top: Option bool + Send + Sync>>, + /// 窗口关闭回调,由上层注入;为 None 时使用默认关闭窗口行为 + on_close_window: Option>, /// Pinned tabs that stay fixed before the scrollable tab list. pinned_tabs: Vec, /// Active pinned tab index. When `None`, a regular tab is active. @@ -768,6 +770,7 @@ impl TabContainer { show_window_controls: false, on_toggle_always_on_top: None, is_always_on_top: None, + on_close_window: None, pinned_tabs: Vec::new(), active_pinned_index: None, split_enabled: false, @@ -830,6 +833,14 @@ impl TabContainer { self } + pub fn with_window_close_action( + mut self, + on_close_window: Arc, + ) -> Self { + self.on_close_window = Some(on_close_window); + self + } + pub fn with_split_enabled(mut self, enabled: bool) -> Self { self.split_enabled = enabled; self @@ -3463,6 +3474,7 @@ impl TabContainer { is_linux, is_windows, false, + None, )) .child(self.render_control_button( if is_maximized { "restore" } else { "maximize" }, @@ -3475,6 +3487,7 @@ impl TabContainer { is_linux, is_windows, false, + None, )) .child(self.render_control_button( "close", @@ -3483,6 +3496,7 @@ impl TabContainer { is_linux, is_windows, true, + self.on_close_window.clone(), )) } @@ -3494,6 +3508,7 @@ impl TabContainer { is_linux: bool, is_windows: bool, is_close: bool, + on_close_window: Option>, ) -> impl IntoElement { div() .id(id) @@ -3534,7 +3549,13 @@ impl TabContainer { match control_area { WindowControlArea::Min => window.minimize_window(), WindowControlArea::Max => window.zoom_window(), - WindowControlArea::Close => window.remove_window(), + WindowControlArea::Close => { + if let Some(on_close_window) = on_close_window.clone() { + on_close_window(window, cx); + } else { + window.remove_window(); + } + } _ => {} } }) diff --git a/crates/core/src/tab_container_split_tests.rs b/crates/core/src/tab_container_split_tests.rs index 664fc30dfa..1fbb1ebb1c 100644 --- a/crates/core/src/tab_container_split_tests.rs +++ b/crates/core/src/tab_container_split_tests.rs @@ -57,3 +57,11 @@ fn split_visibility_uses_pinned_tab_capability_when_pinned_is_active() { Some(true), )); } + +#[test] +fn linux_close_control_uses_injected_window_close_callback() { + let source = include_str!("tab_container.rs"); + + assert!(source.contains("with_window_close_action")); + assert!(source.contains("on_close_window")); +} diff --git a/crates/db_view/src/sql_editor_view.rs b/crates/db_view/src/sql_editor_view.rs index f76c707395..d712cf7ba1 100644 --- a/crates/db_view/src/sql_editor_view.rs +++ b/crates/db_view/src/sql_editor_view.rs @@ -147,6 +147,10 @@ fn current_sql_statement( ) -> Option { let cursor_offset = clamp_to_char_boundary(editor_text, cursor_offset); let (prefix, suffix) = editor_text.split_at(cursor_offset); + let statements = parse_sql_statements(editor_text, database_type.clone()); + if statements.is_empty() { + return None; + } if cursor_starts_next_statement(prefix, suffix) { if let Some(statement) = parse_sql_statements(suffix, database_type.clone()) .into_iter() @@ -155,14 +159,12 @@ fn current_sql_statement( return Some(statement); } } - parse_sql_statements(prefix, database_type.clone()) - .into_iter() - .last() - .or_else(|| { - parse_sql_statements(editor_text, database_type) - .into_iter() - .next() - }) + let prefix_statement_count = parse_sql_statements(prefix, database_type).len(); + let statement_index = prefix_statement_count.saturating_sub(1); + + statements + .get(statement_index.min(statements.len() - 1)) + .cloned() } fn parse_sql_statements(sql: &str, database_type: DatabaseType) -> Vec { @@ -1962,6 +1964,18 @@ mod tests { assert_eq!("select * from orders", actual); } + #[test] + fn run_query_text_uses_full_multiline_statement_when_cursor_is_inside() { + let sql = "select * from users;\nselect id,\n name\nfrom orders\nwhere active = 1;\nselect * from products;"; + let cursor_offset = sql.find("name").expect("statement exists") + "na".len(); + let actual = sql_text_for_run_current(sql, "", cursor_offset, DatabaseType::MySQL); + + assert_eq!( + "select id,\n name\nfrom orders\nwhere active = 1", + actual + ); + } + #[test] fn run_query_text_ignores_semicolon_inside_string() { let sql = "select 1;\nselect ';not delimiter' as value;\nselect 3;"; diff --git a/crates/extension-runtime/src/connection_import_provider.rs b/crates/extension-runtime/src/connection_import_provider.rs index 55bef1033a..6d0fd562a4 100644 --- a/crates/extension-runtime/src/connection_import_provider.rs +++ b/crates/extension-runtime/src/connection_import_provider.rs @@ -29,6 +29,21 @@ pub struct ManifestConnectionImporter { pub descriptor: ImporterDescriptor, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ManualConnectionImportFile { + pub importer_id: String, + pub path: PathBuf, +} + +impl ManualConnectionImportFile { + pub fn new(importer_id: impl Into, path: impl Into) -> Self { + Self { + importer_id: importer_id.into(), + path: path.into(), + } + } +} + pub fn list_manifest_connection_importers( composite_root: &Path, ) -> Result> { @@ -155,6 +170,22 @@ pub async fn preview_manifest_connection_importers( composite_root: &Path, importer_ids: &[String], include_passwords: bool, +) -> Result> { + preview_manifest_connection_importers_with_files( + composite_root, + importer_ids, + include_passwords, + &[], + ) + .await +} + +#[cfg(feature = "wasm-components")] +pub async fn preview_manifest_connection_importers_with_files( + composite_root: &Path, + importer_ids: &[String], + include_passwords: bool, + manual_files: &[ManualConnectionImportFile], ) -> Result> { let importers = list_manifest_connection_importers(composite_root)?; let mut records = Vec::new(); @@ -170,15 +201,14 @@ pub async fn preview_manifest_connection_importers( &module, ) .with_context(|| format!("加载连接导入 Wasm 失败: {}", module.display()))?; - let host = ManifestConnectionImportHost::new( - importer.candidates.clone(), - importer.permissions.clone(), - ); + let (candidates, permissions) = + connection_import_inputs(&importer, &descriptor_id, manual_files); + let host = ManifestConnectionImportHost::new(candidates, permissions.clone()); let state = ConnectionImportHostState::new( importer.extension_id, importer.descriptor.id, host, - PermissionSet::new(importer.permissions), + PermissionSet::new(permissions), ); runtime .preview(state, include_passwords) @@ -204,6 +234,42 @@ pub async fn preview_manifest_connection_importers( Ok(records) } +fn connection_import_inputs( + importer: &ManifestConnectionImporter, + descriptor_id: &str, + manual_files: &[ManualConnectionImportFile], +) -> (Vec, Vec) { + let mut candidates = importer.candidates.clone(); + let mut permissions = importer.permissions.clone(); + let (manual_candidates, manual_permissions) = + manual_file_candidates(descriptor_id, manual_files); + candidates.extend(manual_candidates); + permissions.extend(manual_permissions); + (candidates, permissions) +} + +pub(crate) fn manual_file_candidates( + importer_id: &str, + manual_files: &[ManualConnectionImportFile], +) -> (Vec, Vec) { + let mut candidates = Vec::new(); + let mut permissions = Vec::new(); + for (index, file) in manual_files + .iter() + .filter(|file| file.importer_id == importer_id) + .enumerate() + { + let path = file.path.to_string_lossy().to_string(); + candidates.push(CandidateFile { + id: format!("manual-file-{index}"), + platform: None, + path: path.clone(), + }); + permissions.push(format!("fs:read:{path}")); + } + (candidates, permissions) +} + fn scan_error_report(importer_id: String, message: String) -> ImportScanReport { ImportScanReport { importer_id, @@ -222,6 +288,16 @@ pub async fn preview_manifest_connection_importers( Err(anyhow::anyhow!("wasm component runtime is disabled")) } +#[cfg(not(feature = "wasm-components"))] +pub async fn preview_manifest_connection_importers_with_files( + _composite_root: &Path, + _importer_ids: &[String], + _include_passwords: bool, + _manual_files: &[ManualConnectionImportFile], +) -> Result> { + Err(anyhow::anyhow!("wasm component runtime is disabled")) +} + fn runtime_id(manifest: &Manifest, contrib: &ConnectionImporterContrib) -> String { if !contrib.runtime_id.is_empty() { return contrib.runtime_id.clone(); @@ -235,6 +311,7 @@ fn runtime_id(manifest: &Manifest, contrib: &ConnectionImporterContrib) -> Strin } fn descriptor(manifest: &Manifest, contrib: &ConnectionImporterContrib) -> ImporterDescriptor { + let manual_file_pick_prompt = manual_file_pick_prompt(contrib); ImporterDescriptor { id: format!("{}/{}", manifest.id, contrib.id), display_name: contrib.display_name.clone(), @@ -254,12 +331,23 @@ fn descriptor(manifest: &Manifest, contrib: &ConnectionImporterContrib) -> Impor capabilities: ImporterCapabilities { supports_scan: true, supports_password_import: false, - supports_manual_file_pick: !contrib.candidate_files.is_empty(), + supports_manual_file_pick: manual_file_pick_prompt.is_some(), + manual_file_pick_prompt, supports_incremental_preview: false, }, } } +fn manual_file_pick_prompt(contrib: &ConnectionImporterContrib) -> Option { + contrib + .manual_file_pick + .prompt + .as_deref() + .map(str::trim) + .filter(|prompt| !prompt.is_empty()) + .map(str::to_string) +} + fn parse_platform(value: &str) -> Option { match value { "macos" => Some(Platform::Macos), diff --git a/crates/extension-runtime/src/connection_import_provider/host.rs b/crates/extension-runtime/src/connection_import_provider/host.rs index 33a4fbcc53..aba0113667 100644 --- a/crates/extension-runtime/src/connection_import_provider/host.rs +++ b/crates/extension-runtime/src/connection_import_provider/host.rs @@ -6,9 +6,12 @@ use connection_import_protocol::{ }; use extension_component::{CandidateFileAccess, ExtensionConnectionImportHost, PermissionSet}; +#[cfg(test)] +use super::{ManualConnectionImportFile, manual_file_candidates}; + pub(crate) struct ManifestConnectionImportHost { candidates: Vec, - permissions: PermissionSet, + permissions: Vec, } impl ManifestConnectionImportHost { @@ -19,12 +22,31 @@ impl ManifestConnectionImportHost { { Self { candidates, - permissions: PermissionSet::new(permissions), + permissions: permissions + .into_iter() + .map(|permission| permission.as_ref().to_string()) + .collect(), } } + #[cfg(test)] + pub(crate) fn with_manual_files( + mut self, + importer_id: &str, + manual_files: &[ManualConnectionImportFile], + ) -> Self { + let (manual_candidates, manual_permissions) = + manual_file_candidates(importer_id, manual_files); + self.candidates.extend(manual_candidates); + self.permissions.extend(manual_permissions); + self + } + fn candidate_access(&self) -> CandidateFileAccess { - CandidateFileAccess::new(self.candidates.clone(), self.permissions.clone()) + CandidateFileAccess::new( + self.candidates.clone(), + PermissionSet::new(self.permissions.iter().map(String::as_str)), + ) } } @@ -83,7 +105,9 @@ impl ExtensionConnectionImportHost for ManifestConnectionImportHost { fn read_secret(&self, query: SecretQuery) -> SecretResult { let (namespace, key) = secret_scope(&query); - if !self.permissions.allows_secret_read(&namespace, &key) { + if !PermissionSet::new(self.permissions.iter().map(String::as_str)) + .allows_secret_read(&namespace, &key) + { return SecretResult::PermissionDenied; } read_platform_secret(&query) diff --git a/crates/extension-runtime/src/connection_import_provider_tests.rs b/crates/extension-runtime/src/connection_import_provider_tests.rs index fa944739a0..c70ae34238 100644 --- a/crates/extension-runtime/src/connection_import_provider_tests.rs +++ b/crates/extension-runtime/src/connection_import_provider_tests.rs @@ -4,8 +4,8 @@ use connection_import_protocol::{CandidateFile, HostAccessError, ImportRecordKin use extension_component::ExtensionConnectionImportHost; use crate::connection_import_provider::{ - ManifestConnectionImportHost, preview_manifest_connection_importers, - scan_manifest_connection_importers, + ManifestConnectionImportHost, ManualConnectionImportFile, + preview_manifest_connection_importers, scan_manifest_connection_importers, }; mod fixtures; @@ -42,6 +42,9 @@ fn connection_import_provider_lists_manifest_importers_with_scoped_ids() { "displayName": "Navicat", "outputKinds": ["database"], "platforms": ["macos"], + "manualFilePick": { + "prompt": "选择 Navicat 导出的 connection.ncx 文件" + }, "candidateFiles": [{ "id": "navicat-conn", "platform": "macos", @@ -64,6 +67,20 @@ fn connection_import_provider_lists_manifest_importers_with_scoped_ids() { assert_eq!("Navicat", importers[0].descriptor.display_name); assert_eq!("navicat-importer", importers[0].runtime_id); assert_eq!(extension_dir, importers[0].extension_dir); + assert!( + importers[0] + .descriptor + .capabilities + .supports_manual_file_pick + ); + assert_eq!( + Some("选择 Navicat 导出的 connection.ncx 文件"), + importers[0] + .descriptor + .capabilities + .manual_file_pick_prompt + .as_deref() + ); } #[test] @@ -212,3 +229,29 @@ fn manifest_connection_import_host_requires_manifest_fs_read_permission() { error ); } + +#[test] +fn manifest_connection_import_host_reads_user_selected_manual_files() { + let tmp = tempfile::TempDir::new().unwrap(); + let manual_path = tmp.path().join("connection.ncx"); + fs::write(&manual_path, "").unwrap(); + let importer_id = "com.onetcli.importer.navicat/navicat"; + let host = ManifestConnectionImportHost::new(Vec::new(), Vec::::new()) + .with_manual_files( + importer_id, + &[ManualConnectionImportFile::new( + importer_id, + manual_path.clone(), + )], + ); + + let candidates = host.list_candidate_files("navicat"); + + assert_eq!(1, candidates.len()); + assert_eq!("manual-file-0", candidates[0].id); + assert_eq!(manual_path.to_string_lossy(), candidates[0].path); + assert_eq!( + b"", + host.read_file("manual-file-0").unwrap().as_slice() + ); +} diff --git a/crates/extension-runtime/src/extension/manifest/contributes.rs b/crates/extension-runtime/src/extension/manifest/contributes.rs index 25fc61f062..fba9f794d8 100644 --- a/crates/extension-runtime/src/extension/manifest/contributes.rs +++ b/crates/extension-runtime/src/extension/manifest/contributes.rs @@ -103,10 +103,18 @@ pub struct ConnectionImporterContrib { pub output_kinds: Vec, #[serde(default)] pub platforms: Vec, + #[serde(default, rename = "manualFilePick")] + pub manual_file_pick: ManualFilePickContrib, #[serde(default, rename = "candidateFiles")] pub candidate_files: Vec, } +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +pub struct ManualFilePickContrib { + #[serde(default)] + pub prompt: Option, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] pub struct CandidateFileContrib { pub id: String, diff --git a/crates/extension-runtime/src/extension/manifest/parser_tests.rs b/crates/extension-runtime/src/extension/manifest/parser_tests.rs index a122f99ff6..6ea42e8f33 100644 --- a/crates/extension-runtime/src/extension/manifest/parser_tests.rs +++ b/crates/extension-runtime/src/extension/manifest/parser_tests.rs @@ -88,6 +88,9 @@ fn manifest_parses_connection_importers() { "icon": "database", "outputKinds": ["database"], "platforms": ["macos"], + "manualFilePick": { + "prompt": "选择 Navicat 导出的 connection.ncx 文件" + }, "candidateFiles": [{ "id": "navicat-macos-cc-conn", "platform": "macos", @@ -107,6 +110,10 @@ fn manifest_parses_connection_importers() { assert_eq!(Some("database"), importer.icon.as_deref()); assert_eq!(vec!["database"], importer.output_kinds); assert_eq!(vec!["macos"], importer.platforms); + assert_eq!( + Some("选择 Navicat 导出的 connection.ncx 文件"), + importer.manual_file_pick.prompt.as_deref() + ); assert_eq!(1, importer.candidate_files.len()); assert_eq!("navicat-macos-cc-conn", importer.candidate_files[0].id); assert_eq!("macos", importer.candidate_files[0].platform); diff --git a/docs/superpowers/plans/2026-07-07-app-quit-confirm.md b/docs/superpowers/plans/2026-07-07-app-quit-confirm.md new file mode 100644 index 0000000000..6ec4d1221f --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-app-quit-confirm.md @@ -0,0 +1,421 @@ +# App Quit Confirmation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an application-level quit confirmation and route confirmed app quit through every pane's `TabContainer::close_all_tabs()` so each tab's `try_close()` contract is respected. + +**Architecture:** `OnetCliApp` owns the quit prompt/in-progress state and handles `QuitApp`, main-window should-close, and Linux custom close control through one `request_quit` path. `SplitTabContainer` owns cross-pane close orchestration and delegates each pane to existing `TabContainer::close_all_tabs()`. + +**Tech Stack:** Rust 2024, GPUI, gpui-component dialogs, `one-core` tab/split container APIs, rust-i18n locale YAML. + +--- + +### Task 1: Quit State Contract + +**Files:** +- Modify: `main/src/onetcli_app.rs` + +- [x] **Step 1: Write failing tests for the pure quit-state helper** + +Add a small helper enum and tests before implementation: + +```rust +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QuitRequestDecision { + OpenPrompt, + Ignore, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct QuitRequestState { + prompt_open: bool, + in_progress: bool, +} + +#[cfg(test)] +mod tests { + #[test] + fn quit_state_opens_prompt_for_first_request() { + let mut state = super::QuitRequestState::default(); + assert_eq!(super::QuitRequestDecision::OpenPrompt, state.request()); + assert!(state.prompt_open); + } + + #[test] + fn quit_state_ignores_duplicate_prompt_and_in_progress_requests() { + let mut prompt_state = super::QuitRequestState::default(); + prompt_state.prompt_open = true; + assert_eq!(super::QuitRequestDecision::Ignore, prompt_state.request()); + + let mut running_state = super::QuitRequestState::default(); + running_state.in_progress = true; + assert_eq!(super::QuitRequestDecision::Ignore, running_state.request()); + } + + #[test] + fn quit_state_resets_after_cancel_or_failed_close() { + let mut state = super::QuitRequestState { + prompt_open: true, + in_progress: false, + }; + state.cancel_prompt(); + assert_eq!(super::QuitRequestState::default(), state); + + state.prompt_open = true; + state.confirm_prompt(); + assert!(state.in_progress); + state.finish_close(false); + assert_eq!(super::QuitRequestState::default(), state); + } +} +``` + +- [x] **Step 2: Run test and verify RED** + +Run: + +```bash +rtk cargo test -p main quit_state +``` + +Expected: fails because `QuitRequestState::request`, `cancel_prompt`, `confirm_prompt`, and `finish_close` are not implemented. + +- [x] **Step 3: Implement the helper minimally** + +Add methods: + +```rust +impl QuitRequestState { + fn request(&mut self) -> QuitRequestDecision { + if self.prompt_open || self.in_progress { + return QuitRequestDecision::Ignore; + } + self.prompt_open = true; + QuitRequestDecision::OpenPrompt + } + + fn cancel_prompt(&mut self) { + self.prompt_open = false; + } + + fn confirm_prompt(&mut self) -> bool { + if self.in_progress { + return false; + } + self.prompt_open = false; + self.in_progress = true; + true + } + + fn finish_close(&mut self, closed: bool) { + if !closed { + self.in_progress = false; + } + } +} +``` + +- [x] **Step 4: Run GREEN** + +Run: + +```bash +rtk cargo test -p main quit_state +``` + +Expected: the three `quit_state_*` tests pass. + +### Task 2: Split Pane Close Orchestration + +**Files:** +- Modify: `crates/core/src/split_tab_container.rs` +- Modify: `crates/core/src/tab_container_split_tests.rs` + +- [x] **Step 1: Write failing pure traversal tests** + +Add tests that define and verify a pure traversal helper: + +```rust +#[test] +fn split_close_order_visits_all_leaf_panes_left_to_right() { + let tree = test_tree(vec![ + SplitCloseTestNode::Leaf("primary"), + SplitCloseTestNode::Split(vec![ + SplitCloseTestNode::Leaf("right_top"), + SplitCloseTestNode::Leaf("right_bottom"), + ]), + ]); + assert_eq!( + vec!["primary", "right_top", "right_bottom"], + split_close_leaf_order(&tree) + ); +} + +#[test] +fn split_close_sequence_stops_after_first_rejection() { + assert_eq!( + (false, vec!["primary", "right"]), + close_sequence_until_rejected(vec![ + ("primary", true), + ("right", false), + ("skipped", true), + ]) + ); +} +``` + +- [x] **Step 2: Run test and verify RED** + +Run: + +```bash +rtk cargo test -p one-core split_close +``` + +Expected: fails because the traversal/sequence helpers do not exist. + +- [x] **Step 3: Implement traversal and orchestration** + +Implement: + +```rust +impl SplitNode { + fn collect_panes(&self, panes: &mut Vec>) { + match self { + SplitNode::Leaf(pane) => panes.push(pane.clone()), + SplitNode::Split { children, .. } => { + for child in children { + child.collect_panes(panes); + } + } + } + } +} + +impl SplitTabContainer { + pub fn close_all_tabs( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> Task { + let mut panes = Vec::new(); + self.root.collect_panes(&mut panes); + let window_id = cx.active_window(); + + cx.spawn(async move |_handle, cx| { + for pane in panes { + let task = cx.update_window(window_id.expect("No active window"), |_, window, cx| { + pane.update(cx, |pane, cx| pane.close_all_tabs(window, cx)) + }); + match task { + Ok(task) if task.await => {} + Ok(_) | Err(_) => return false, + } + } + true + }) + } +} +``` + +Keep helper tests focused on deterministic order and early stop. Do not duplicate `TabContainer::close_all_tabs()` tests. + +- [x] **Step 4: Run GREEN** + +Run: + +```bash +rtk cargo test -p one-core split_close +``` + +Expected: new split close tests pass. + +### Task 3: Main App Quit Wiring + +**Files:** +- Modify: `main/src/onetcli_app.rs` +- Modify: `crates/core/src/tab_container.rs` +- Modify: `main/locales/main.yml` + +- [x] **Step 1: Write failing structural tests** + +Add tests in `main/src/onetcli_app.rs`: + +```rust +#[test] +fn quit_action_does_not_call_cx_quit_directly() { + let source = include_str!("onetcli_app.rs"); + let quit_fn = function_source(source, "fn quit_app"); + assert!(!quit_fn.contains("cx.quit()")); + assert!(quit_fn.contains("request_active_window_quit")); +} + +#[test] +fn onetcli_app_registers_window_close_guard() { + let source = include_str!("onetcli_app.rs"); + assert!(source.contains("on_window_should_close")); + assert!(source.contains("request_quit")); +} +``` + +Add a structural test in `crates/core/src/tab_container.rs` or existing split/tab tests to assert the close control has an injected callback path: + +```rust +#[test] +fn linux_close_control_uses_injected_window_close_callback() { + let source = include_str!("tab_container.rs"); + assert!(source.contains("on_close_window")); + assert!(source.contains("with_window_close_action")); +} +``` + +- [x] **Step 2: Run tests and verify RED** + +Run: + +```bash +rtk cargo test -p main quit_action +rtk cargo test -p one-core linux_close_control +``` + +Expected: tests fail because direct quit and direct Linux close are still present. + +- [x] **Step 3: Implement app quit request path** + +Add `quit_state: QuitRequestState` to `OnetCliApp`. + +Add methods: + +```rust +fn request_active_window_quit(cx: &mut App) { + let Some(active_window) = cx.active_window() else { + cx.quit(); + return; + }; + let Some(app) = cx.try_global::().map(|global| global.app.clone()) else { + cx.quit(); + return; + }; + cx.defer(move |cx| { + let _ = active_window.update(cx, |_, window, cx| { + app.update(cx, |app, cx| { + app.request_quit(window, cx); + }); + }); + }); +} + +fn request_quit(&mut self, window: &mut Window, cx: &mut Context) { + if self.quit_state.request() == QuitRequestDecision::OpenPrompt { + self.show_quit_confirmation(window, cx); + } +} + +fn show_quit_confirmation(&mut self, window: &mut Window, cx: &mut Context) { + let app = cx.entity().downgrade(); + window.open_dialog(cx, move |dialog, _window, _cx| { + let app = app.clone(); + dialog + .title(t!("Quit.confirm_title").to_string()) + .child(t!("Quit.confirm_message").to_string()) + .confirm() + .on_ok(move |_, window, cx| { + let _ = app.update(cx, |app, cx| app.confirm_quit(window, cx)); + true + }) + }); +} + +fn confirm_quit(&mut self, window: &mut Window, cx: &mut Context) { + if !self.quit_state.confirm_prompt() { + return; + } + let close_task = self + .split_container + .update(cx, |split, cx| split.close_all_tabs(window, cx)); + cx.spawn(async move |this, cx| { + let can_quit = close_task.await; + let _ = this.update(cx, |app, cx| { + app.quit_state.finish_close(can_quit); + if can_quit { + cx.quit(); + } + }); + }) + .detach(); +} +``` + +`show_quit_confirmation` uses `window.open_dialog(cx, move |dialog, _window, _cx| { dialog })`, `.confirm()`, `DialogButtonProps::default().ok_text(t!("Quit.confirm_action").to_string()).cancel_text(t!("Common.cancel").to_string())`, and the new locale keys. + +`confirm_quit` runs `self.split_container.update(cx, |split, cx| split.close_all_tabs(window, cx))`, awaits the task, and calls `cx.quit()` only when it resolves to `true`. + +- [x] **Step 4: Wire entry points** + +Update: + +```rust +fn quit_app(cx: &mut App) { + request_active_window_quit(cx); +} +``` + +Register `window.on_window_should_close()` in `OnetCliApp::new`, calling `request_quit` and returning `false`. + +Pass a Linux close callback into primary `TabContainer` via a new `with_window_close_action` builder. + +- [x] **Step 5: Add locale keys** + +Add `Quit.confirm_title`, `Quit.confirm_message`, and `Quit.confirm_action` to `main/locales/main.yml`. + +- [x] **Step 6: Run GREEN** + +Run: + +```bash +rtk cargo test -p main quit +rtk cargo test -p one-core linux_close_control +``` + +Expected: new wiring tests pass. + +### Task 4: Final Verification + +**Files:** +- Verify all files touched in Tasks 1-3. + +- [x] **Step 1: Format** + +Run: + +```bash +rtk cargo fmt +``` + +- [x] **Step 2: Targeted tests** + +Run: + +```bash +rtk cargo test -p one-core split_close +rtk cargo test -p main quit +``` + +- [x] **Step 3: Compile main app** + +Run: + +```bash +rtk cargo check -p main +``` + +- [x] **Step 4: Inspect diff** + +Run: + +```bash +rtk git diff --stat +rtk git diff +``` + +Confirm the implementation only touches the quit confirmation feature, locale text, and tests. diff --git a/docs/superpowers/specs/2026-07-07-app-quit-confirm-design.md b/docs/superpowers/specs/2026-07-07-app-quit-confirm-design.md new file mode 100644 index 0000000000..0fe715e44a --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-app-quit-confirm-design.md @@ -0,0 +1,189 @@ +# App Quit Confirmation Design + +## Goal + +Prevent accidental application exits by showing an application-level confirmation dialog before quitting, while guaranteeing that every closable work tab still goes through the existing `TabContent::try_close()` contract. + +## Non-Goals + +- Do not replace per-tab close confirmation logic. +- Do not add new unsaved-state APIs to business tabs. +- Do not force-close tabs during normal application quit. +- Do not change update-install quit behavior that already follows an explicit update flow. +- Do not persist quit-confirmation preferences in this phase. + +## Requirements + +- Cmd-Q on macOS and Alt-F4 on non-macOS must no longer call `cx.quit()` directly. +- Main-window close requests must be intercepted before the window is removed. +- Linux custom title-bar close control must enter the same quit request path as keyboard quit. +- The first quit request opens one application-level confirmation dialog. +- Repeated quit requests while the confirmation dialog is open or tab closing is already in progress must not open duplicate dialogs or start concurrent close tasks. +- Confirming the dialog must close all regular work tabs through `TabContainer::close_all_tabs()`. +- Split panes must be included. Tabs in secondary panes must not be skipped. +- If any tab's `try_close()` returns `false`, application quit is canceled and the main window remains open. +- If all closable work tabs approve closing, the app quits. +- Pinned tabs such as Home do not block quit. +- Existing per-tab confirmations remain authoritative for dirty data, running sessions, or tab-specific close rules. + +## User Experience + +When the user requests application quit, show a modal confirmation dialog: + +- Title: "Quit application?" +- Message: "All tabs will be closed. Some tabs may ask for additional confirmation before quitting." +- Confirm button: "Quit" +- Cancel button: existing common cancel text. + +Canceling the dialog leaves the app unchanged. Confirming starts the tab close sequence. During that sequence, per-tab dialogs may appear in the order selected by the close traversal. If the user cancels any per-tab confirmation, the quit attempt stops. + +The application-level confirmation is intentionally shown for every normal app quit request. This protects against accidental Cmd-Q, Alt-F4, or title-bar close even when no dirty tab is currently known. + +## Architecture + +The quit flow becomes: + +```text +QuitApp action + -> OnetCliApp::request_quit() + -> confirmation dialog + -> OnetCliApp::confirm_quit() + -> SplitTabContainer::close_all_tabs() + -> each TabContainer::close_all_tabs() + -> each TabContent::try_close() + -> cx.quit() only if every close task returns true + +Window should-close callback + -> OnetCliApp::request_quit() + -> returns false to prevent direct close + +Linux custom close button + -> OnetCliApp::request_quit() +``` + +`OnetCliApp` owns application-level quit state: + +```rust +pub struct OnetCliApp { + split_container: Entity, + quit_prompt_open: bool, + quit_in_progress: bool, +} +``` + +`quit_prompt_open` prevents duplicate confirmation dialogs. `quit_in_progress` prevents concurrent tab-close tasks after the user confirms quit. + +`SplitTabContainer` gains a public close orchestrator: + +```rust +pub fn close_all_tabs( + &mut self, + window: &mut Window, + cx: &mut Context, +) -> Task +``` + +The method collects all current panes from the split tree in a stable left-to-right, top-to-bottom traversal and calls each pane's existing `TabContainer::close_all_tabs()` in order. It returns `false` immediately when any pane returns `false`. + +`TabContainer::close_all_tabs()` keeps its current role. It activates each regular closable tab, calls that tab content's `try_close()`, removes the tab only after approval, and returns `false` when a tab refuses to close. + +## Entry Points + +### Keyboard Quit + +`quit_app(cx)` changes from direct `cx.quit()` to a request routed through the active main window: + +1. Get the active window. +2. Get the active `OnetCliApp` view or global quit controller. +3. Defer into the window context. +4. Call `request_quit(window, cx)`. + +If no active window exists, the function may fall back to `cx.quit()` because no tab container can be consulted. + +### Main Window Close + +`OnetCliApp::new` registers `window.on_window_should_close()` for the main window. The callback calls `request_quit()` and returns `false`. This ensures system close requests do not bypass tab close checks. + +### Linux Custom Close + +The primary `TabContainer` already receives custom Linux window controls. Its close button currently calls `window.remove_window()` directly. This button should call the same quit request path used by `QuitApp`, so Linux client-side controls behave like system quit. + +## Dialog Behavior + +The dialog uses the existing `window.open_dialog()` and `Dialog::confirm()` pattern. It should reuse `DialogButtonProps` to set the confirm and cancel labels. + +The confirm callback: + +1. Clears `quit_prompt_open`. +2. Calls `confirm_quit(window, cx)`. +3. Returns `true` so the application-level dialog closes before any per-tab confirmation appears. + +The cancel callback clears `quit_prompt_open` and returns `true`. + +If the dialog is closed through Escape or any close affordance, the close handler also clears `quit_prompt_open`. + +## Failure And Cancellation + +If any tab rejects close: + +- Do not call `cx.quit()`. +- Reset `quit_in_progress` so the user can request quit again. +- Leave already closed tabs closed. This matches the existing sequential `close_all_tabs()` behavior. +- Leave the rejecting tab or its pane active when possible, because `TabContainer::close_all_tabs()` activates each tab before asking it to close. + +If the window or app context is no longer available while the async close task resolves, the task should stop without panicking. + +## Localization + +Add main locale keys under an application quit namespace: + +```yaml +Quit: + confirm_title: + en: Quit application? + zh-CN: 退出应用? + zh-HK: 結束應用程式? + confirm_message: + en: All tabs will be closed. Some tabs may ask for additional confirmation before quitting. + zh-CN: 将关闭所有标签页,部分标签页可能会在退出前要求再次确认。 + zh-HK: 將關閉所有標籤頁,部分標籤頁可能會在結束前要求再次確認。 + confirm_action: + en: Quit + zh-CN: 退出 + zh-HK: 結束 +``` + +Use existing `Common.cancel` for the cancel button. + +## Testing + +Add targeted regression coverage for the quit orchestration. + +`SplitTabContainer` tests should verify: + +- Closing all tabs includes secondary panes. +- A rejecting tab makes the full close task return `false`. +- Closing stops after the first rejection. +- An empty split layout or panes with no regular closable tabs return `true`. + +`OnetCliApp` tests should verify either through GPUI window tests or a small extracted state helper: + +- A quit request opens at most one confirmation dialog. +- A quit request while `quit_in_progress` is true does not start another close task. +- A failed tab-close sequence resets `quit_in_progress`. +- The `QuitApp` handler no longer calls `cx.quit()` directly. + +Suggested verification commands: + +```bash +rtk cargo test -p one-core split_tab_container +rtk cargo test -p main quit +rtk cargo check -p main +``` + +## Implementation Notes + +- Keep the change local to `main/src/onetcli_app.rs`, `crates/core/src/split_tab_container.rs`, and `main/locales/main.yml` unless tests require small helpers. +- Avoid changing business tab implementations. Their existing `try_close()` methods are the source of truth. +- Do not use `force_close_tab_by_id()` in the app quit path. +- Do not route app quit only through `GlobalTabContainer`, because that global currently points at the primary pane and would miss secondary split panes. diff --git a/main/Cargo.toml b/main/Cargo.toml index 6c817a07cf..06cb7b8e56 100644 --- a/main/Cargo.toml +++ b/main/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "main" -version = "0.8.3" +version = "0.8.4" publish.workspace = true edition.workspace = true diff --git a/main/locales/main.yml b/main/locales/main.yml index 7d4a8613cb..6462457e74 100644 --- a/main/locales/main.yml +++ b/main/locales/main.yml @@ -55,6 +55,21 @@ Common: zh-CN: 无 zh-HK: 無 +# 应用退出 +Quit: + confirm_title: + en: Quit application? + zh-CN: 退出应用? + zh-HK: 結束應用程式? + confirm_message: + en: All tabs will be closed. Some tabs may ask for additional confirmation before quitting. + zh-CN: 将关闭所有标签页,部分标签页可能会在退出前要求再次确认。 + zh-HK: 將關閉所有標籤頁,部分標籤頁可能會在結束前要求再次確認。 + confirm_action: + en: Quit + zh-CN: 退出 + zh-HK: 結束 + # MCP 审批 McpApproval: dialog_title: diff --git a/main/src/home/connection_import_actions.rs b/main/src/home/connection_import_actions.rs index 106824b515..f17407601c 100644 --- a/main/src/home/connection_import_actions.rs +++ b/main/src/home/connection_import_actions.rs @@ -1,10 +1,13 @@ +use std::path::PathBuf; + use super::connection_import_draft::EditableImportDraft; use super::connection_import_draft_conversion::stored_connection_duplicate_identity; use crate::setting_tab::GlobalCurrentUser; use connection_import_protocol::{ImportRecord, ImportScanReport}; use extension_runtime::{ connection_import_provider::{ - preview_manifest_connection_importers, scan_manifest_connection_importers, + ManualConnectionImportFile, preview_manifest_connection_importers, + preview_manifest_connection_importers_with_files, scan_manifest_connection_importers, }, extension::{ExtensionKind, extensions_root}, }; @@ -45,6 +48,29 @@ pub(crate) async fn preview_import_records( .map_err(|error| error.to_string()) } +pub(crate) async fn preview_import_records_from_files( + importer_id: String, + file_paths: Vec, + include_passwords: bool, +) -> Result, String> { + if file_paths.is_empty() { + return Ok(Vec::new()); + } + let composite_root = composite_extensions_root()?; + let manual_files = file_paths + .into_iter() + .map(|path| ManualConnectionImportFile::new(importer_id.clone(), path)) + .collect::>(); + preview_manifest_connection_importers_with_files( + &composite_root, + std::slice::from_ref(&importer_id), + include_passwords, + &manual_files, + ) + .await + .map_err(|error| error.to_string()) +} + pub(crate) fn duplicate_connection_name( draft: &EditableImportDraft, existing: &[StoredConnection], diff --git a/main/src/home/connection_import_model_tests.rs b/main/src/home/connection_import_model_tests.rs index 01d733aad3..632d30208e 100644 --- a/main/src/home/connection_import_model_tests.rs +++ b/main/src/home/connection_import_model_tests.rs @@ -127,6 +127,7 @@ fn descriptor(id: &str, platforms: Vec) -> ImporterDescriptor { supports_scan: true, supports_password_import: false, supports_manual_file_pick: true, + manual_file_pick_prompt: None, supports_incremental_preview: false, }, } diff --git a/main/src/home/connection_import_window.rs b/main/src/home/connection_import_window.rs index 7287a9e5b2..4217512633 100644 --- a/main/src/home/connection_import_window.rs +++ b/main/src/home/connection_import_window.rs @@ -1,14 +1,17 @@ +use std::path::PathBuf; + use connection_import_protocol::ImporterDescriptor; use gpui::{ AnyWindowHandle, App, AppContext, AsyncApp, Context, Entity, FocusHandle, Focusable, - WeakEntity, Window, + PathPromptOptions, WeakEntity, Window, }; use one_core::gpui_tokio::Tokio; use one_core::popup_window::{PopupWindowOptions, open_popup_window}; use rust_i18n::t; use super::connection_import_actions::{ - ImportSaveResult, preview_import_records, save_import_draft, scan_import_sources, + ImportSaveResult, preview_import_records, preview_import_records_from_files, save_import_draft, + scan_import_sources, }; use super::connection_import_model::{ImportRowSaveStatus, previewable_source_ids_after_scan}; use crate::home_tab::HomePage; @@ -108,6 +111,61 @@ impl ConnectionImportWindow { .detach(); } + fn import_source_file( + &mut self, + importer_id: String, + prompt: String, + _window: &mut Window, + cx: &mut Context, + ) { + if self.scanning { + return; + } + let future = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: true, + prompt: Some(prompt.into()), + }); + cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { + let Ok(Ok(Some(paths))) = future.await else { + return; + }; + if paths.is_empty() { + return; + } + let _ = this.update(cx, |this, cx| { + this.scanning = true; + this.status_message = None; + cx.notify(); + }); + let result = { + let id = importer_id.clone(); + let selected_paths: Vec = paths.into_iter().collect(); + let task = Tokio::spawn(cx, async move { + preview_import_records_from_files(id, selected_paths, true).await + }); + match task.await { + Ok(result) => result, + Err(error) => Err(format!("导入文件解析任务失败: {error}")), + } + }; + let _ = this.update(cx, |this, cx| { + this.scanning = false; + match result { + Ok(records) => { + let is_empty = records.is_empty(); + this.model.apply_preview_records(records); + this.status_message = is_empty.then(|| "未解析到可导入连接".to_string()); + } + Err(error) => this.status_message = Some(error), + } + cx.notify(); + }); + }) + .detach(); + } + fn save_row(&mut self, record_id: String, cx: &mut Context) { let Some(draft) = self.model.draft(&record_id) else { return; diff --git a/main/src/home/connection_import_window/render/source.rs b/main/src/home/connection_import_window/render/source.rs index 1b3874f6c3..9fd810f27e 100644 --- a/main/src/home/connection_import_window/render/source.rs +++ b/main/src/home/connection_import_window/render/source.rs @@ -1,7 +1,9 @@ use connection_import_protocol::{ImportRecordKind, ImporterAvailability}; +use gpui::prelude::FluentBuilder; use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px}; use gpui_component::{ - ActiveTheme, Disableable, Icon, IconName, Sizable, Size, checkbox::Checkbox, h_flex, v_flex, + ActiveTheme, Disableable, Icon, IconName, Sizable, Size, button::Button, checkbox::Checkbox, + h_flex, v_flex, }; use super::super::ConnectionImportWindow; @@ -13,6 +15,14 @@ pub(super) fn render_source_row( cx: &mut Context, ) -> AnyElement { let importer_id = source.descriptor.id.clone(); + let file_importer_id = importer_id.clone(); + let file_pick_prompt = source + .descriptor + .capabilities + .manual_file_pick_prompt + .clone() + .unwrap_or_else(|| "选择导入文件".to_string()); + let file_pick_tooltip = file_pick_prompt.clone(); h_flex() .items_center() .gap_3() @@ -54,6 +64,26 @@ pub(super) fn render_source_row( .child(availability_text(&source.availability)), ), ) + .when( + source.descriptor.capabilities.supports_manual_file_pick, + |this| { + this.child( + Button::new(format!("import-source-file-{file_importer_id}")) + .small() + .icon(IconName::FolderOpen) + .tooltip(file_pick_tooltip) + .disabled(scanning || !source.selectable) + .on_click(cx.listener(move |this, _, window, cx| { + this.import_source_file( + file_importer_id.clone(), + file_pick_prompt.clone(), + window, + cx, + ); + })), + ) + }, + ) .into_any_element() } diff --git a/main/src/home/connection_import_window_tests.rs b/main/src/home/connection_import_window_tests.rs index 92e10f5acd..0d93e7ade7 100644 --- a/main/src/home/connection_import_window_tests.rs +++ b/main/src/home/connection_import_window_tests.rs @@ -40,6 +40,7 @@ fn descriptor(id: &str) -> ImporterDescriptor { supports_scan: true, supports_password_import: false, supports_manual_file_pick: true, + manual_file_pick_prompt: None, supports_incremental_preview: false, }, } diff --git a/main/src/onetcli_app.rs b/main/src/onetcli_app.rs index 0772577c64..d7c0be5d39 100644 --- a/main/src/onetcli_app.rs +++ b/main/src/onetcli_app.rs @@ -5,12 +5,15 @@ use gpui::{ App, AppContext, Context, Entity, IntoElement, KeyBinding, Keystroke, ParentElement, Render, Styled, Window, actions, div, }; -use gpui_component::{WindowExt, kbd::Kbd, notification::Notification}; +use gpui_component::{WindowExt, dialog::DialogButtonProps, kbd::Kbd, notification::Notification}; use one_core::keybindings::{action_id, rebind_keybindings, shortcuts_for}; use raw_window_handle::HasWindowHandle; #[cfg(any(target_os = "macos", target_os = "windows"))] use raw_window_handle::RawWindowHandle; +use rust_i18n::t; use std::rc::Rc; +#[cfg(not(target_os = "macos"))] +use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; static ALWAYS_ON_TOP: AtomicBool = AtomicBool::new(false); @@ -60,6 +63,13 @@ pub struct GlobalHomePage { impl gpui::Global for GlobalHomePage {} +#[derive(Clone)] +pub struct GlobalOnetCliApp { + pub app: Entity, +} + +impl gpui::Global for GlobalOnetCliApp {} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct InitialPinnedTabLayout { home_tab_id: &'static str, @@ -67,6 +77,47 @@ struct InitialPinnedTabLayout { active_pinned_index: usize, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QuitRequestDecision { + OpenPrompt, + Ignore, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct QuitRequestState { + prompt_open: bool, + in_progress: bool, +} + +impl QuitRequestState { + fn request(&mut self) -> QuitRequestDecision { + if self.prompt_open || self.in_progress { + return QuitRequestDecision::Ignore; + } + self.prompt_open = true; + QuitRequestDecision::OpenPrompt + } + + fn cancel_prompt(&mut self) { + self.prompt_open = false; + } + + fn confirm_prompt(&mut self) -> bool { + if self.in_progress { + return false; + } + self.prompt_open = false; + self.in_progress = true; + true + } + + fn finish_close(&mut self, closed: bool) { + if !closed { + self.in_progress = false; + } + } +} + fn initial_home_tab_layout(startup_default_page: StartupDefaultPage) -> InitialPinnedTabLayout { InitialPinnedTabLayout { home_tab_id: "home", @@ -501,7 +552,32 @@ fn duplicate_tab(cx: &mut App) { } fn quit_app(cx: &mut App) { - cx.quit(); + request_active_window_quit(cx); +} + +fn request_active_window_quit(cx: &mut App) { + let Some(active_window) = cx.active_window() else { + cx.quit(); + return; + }; + cx.defer(move |cx| { + _ = active_window.update(cx, |_, window, cx| { + request_window_quit(window, cx); + }); + }); +} + +fn request_window_quit(window: &mut Window, cx: &mut App) { + let Some(app) = cx + .try_global::() + .map(|global| global.app.clone()) + else { + cx.quit(); + return; + }; + app.update(cx, |app, cx| { + app.request_quit(window, cx); + }); } fn default_shortcut(macos: &'static str, other: &'static str) -> &'static str { @@ -865,10 +941,23 @@ fn init_action_handlers(cx: &mut App) { pub struct OnetCliApp { split_container: Entity, + quit_state: QuitRequestState, } impl OnetCliApp { pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let app_entity = cx.entity(); + cx.set_global(GlobalOnetCliApp { + app: app_entity.clone(), + }); + let app = app_entity.downgrade(); + window.on_window_should_close(cx, move |window, cx| { + let _ = app.update(cx, |app, cx| { + app.request_quit(window, cx); + }); + false + }); + let pane_factory: TabPaneFactory = Rc::new(|window, cx, primary| { let mut container = TabContainer::new(window, cx) .with_tab_bar_colors( @@ -897,8 +986,8 @@ impl OnetCliApp { { if primary { // 窗口置顶按钮注入:点击时切换置顶并刷新按钮视觉状态 - let on_toggle: std::sync::Arc = - std::sync::Arc::new(|_window: &mut Window, cx: &mut App| { + let on_toggle: Arc = + Arc::new(|_window: &mut Window, cx: &mut App| { toggle_always_on_top(cx); if let Some(tab_container) = cx .try_global::() @@ -907,10 +996,13 @@ impl OnetCliApp { tab_container.update(cx, |_, cx| cx.notify()); } }); - let is_active: std::sync::Arc bool + Send + Sync> = - std::sync::Arc::new(|| ALWAYS_ON_TOP.load(Ordering::Relaxed)); + let is_active: Arc bool + Send + Sync> = + Arc::new(|| ALWAYS_ON_TOP.load(Ordering::Relaxed)); + let on_close: Arc = + Arc::new(request_window_quit); container = container .with_window_controls(true) + .with_window_close_action(on_close) .with_always_on_top_control(on_toggle, is_active); } } @@ -954,7 +1046,72 @@ impl OnetCliApp { }); } - Self { split_container } + Self { + split_container, + quit_state: QuitRequestState::default(), + } + } + + fn request_quit(&mut self, window: &mut Window, cx: &mut Context) { + if self.quit_state.request() == QuitRequestDecision::OpenPrompt { + self.show_quit_confirmation(window, cx); + } + } + + fn show_quit_confirmation(&mut self, window: &mut Window, cx: &mut Context) { + let app_for_ok = cx.entity().downgrade(); + let app_for_cancel = cx.entity().downgrade(); + let app_for_close = cx.entity().downgrade(); + window.open_dialog(cx, move |dialog, _window, _cx| { + let app_for_ok = app_for_ok.clone(); + let app_for_cancel = app_for_cancel.clone(); + let app_for_close = app_for_close.clone(); + dialog + .title(t!("Quit.confirm_title").to_string()) + .child(t!("Quit.confirm_message").to_string()) + .confirm() + .button_props( + DialogButtonProps::default() + .ok_text(t!("Quit.confirm_action").to_string()) + .cancel_text(t!("Common.cancel").to_string()), + ) + .on_ok(move |_, window, cx| { + let _ = app_for_ok.update(cx, |app, cx| { + app.confirm_quit(window, cx); + }); + true + }) + .on_cancel(move |_, _, cx| { + let _ = app_for_cancel.update(cx, |app, _cx| { + app.quit_state.cancel_prompt(); + }); + true + }) + .on_close(move |_, _, cx| { + let _ = app_for_close.update(cx, |app, _cx| { + app.quit_state.cancel_prompt(); + }); + }) + }); + } + + fn confirm_quit(&mut self, window: &mut Window, cx: &mut Context) { + if !self.quit_state.confirm_prompt() { + return; + } + let close_task = self + .split_container + .update(cx, |split, cx| split.close_all_tabs(window, cx)); + cx.spawn(async move |this, cx| { + let can_quit = close_task.await; + let _ = this.update(cx, |app, cx| { + app.quit_state.finish_close(can_quit); + if can_quit { + cx.quit(); + } + }); + }) + .detach(); } } @@ -1019,6 +1176,81 @@ mod tests { assert_eq!("⌃⌘T", super::shortcut_label("ctrl-cmd-t")); } + #[test] + fn quit_action_routes_through_active_window_quit_request() { + let source = include_str!("onetcli_app.rs"); + let start = source.find("fn quit_app").expect("quit_app function"); + let end = source[start..] + .find("\n}\n\nfn request_active_window_quit") + .map(|offset| start + offset) + .expect("quit_app function end"); + let quit_fn = &source[start..end]; + + assert!(!quit_fn.contains("cx.quit()")); + assert!(quit_fn.contains("request_active_window_quit(cx)")); + } + + #[test] + fn onetcli_app_registers_window_close_guard() { + let source = include_str!("onetcli_app.rs"); + let start = source.find("pub fn new").expect("OnetCliApp::new"); + let end = source[start..] + .find("\n let pane_factory") + .map(|offset| start + offset) + .expect("OnetCliApp::new setup"); + let new_fn = &source[start..end]; + + assert!(new_fn.contains("on_window_should_close")); + assert!(new_fn.contains("request_quit(window, cx)")); + } + + #[test] + fn quit_state_opens_prompt_for_first_request() { + let mut state = super::QuitRequestState::default(); + + assert_eq!(super::QuitRequestDecision::OpenPrompt, state.request()); + assert!(state.prompt_open); + } + + #[test] + fn quit_state_ignores_duplicate_prompt_and_in_progress_requests() { + let mut prompt_state = super::QuitRequestState { + prompt_open: true, + in_progress: false, + }; + assert_eq!(super::QuitRequestDecision::Ignore, prompt_state.request()); + + let mut running_state = super::QuitRequestState { + prompt_open: false, + in_progress: true, + }; + assert_eq!(super::QuitRequestDecision::Ignore, running_state.request()); + } + + #[test] + fn quit_state_resets_after_cancel_or_failed_close() { + let mut state = super::QuitRequestState { + prompt_open: true, + in_progress: false, + }; + + state.cancel_prompt(); + assert_eq!(super::QuitRequestState::default(), state); + + state.prompt_open = true; + assert!(state.confirm_prompt()); + assert_eq!( + super::QuitRequestState { + prompt_open: false, + in_progress: true, + }, + state + ); + + state.finish_close(false); + assert_eq!(super::QuitRequestState::default(), state); + } + #[cfg(target_os = "macos")] #[test] fn macos_window_level_maps_toggle_state() {