Run chats independently
This commit is contained in:
@@ -9,6 +9,14 @@ Choose **Add project** to give the coding agent access to a folder. The agent's
|
|||||||
file tools are bounded to that project. Use **File > New Chat** (`⌘N`) to start
|
file tools are bounded to that project. Use **File > New Chat** (`⌘N`) to start
|
||||||
a chat in the active project. Chats are saved after their first message and can
|
a chat in the active project. Chats are saved after their first message and can
|
||||||
be renamed, pinned, archived, compacted, rebuilt, or deleted from the sidebar.
|
be renamed, pinned, archived, compacted, rebuilt, or deleted from the sidebar.
|
||||||
|
An active chat has a green dot beside its title. You can switch chats or start
|
||||||
|
another one while it works; each chat keeps its own transcript, queued prompts,
|
||||||
|
tools, approvals, context, and interactive UI state. Model inference shares the
|
||||||
|
single loaded runtime so model weights are not duplicated, while independent
|
||||||
|
tool work continues concurrently.
|
||||||
|
|
||||||
|
Quitting with active chats asks for confirmation. Confirming stops their model
|
||||||
|
and tool work; canceling leaves every chat running.
|
||||||
|
|
||||||
Use **File > Export Chat as Markdown** (`⇧⌘S`) to save the visible conversation,
|
Use **File > Export Chat as Markdown** (`⇧⌘S`) to save the visible conversation,
|
||||||
including reasoning and tool results. System-only messages are omitted.
|
including reasoning and tool results. System-only messages are omitted.
|
||||||
|
|||||||
361
src/app.rs
361
src/app.rs
@@ -66,6 +66,8 @@ pub(crate) struct App {
|
|||||||
/// Unsaved sessions, keyed by project. A draft only becomes a `sessions` row
|
/// Unsaved sessions, keyed by project. A draft only becomes a `sessions` row
|
||||||
/// when its first chat turn is stored, so empty ones vanish on restart.
|
/// when its first chat turn is stored, so empty ones vanish on restart.
|
||||||
drafts: HashMap<i32, String>,
|
drafts: HashMap<i32, String>,
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
background_chats: HashMap<i32, ChatSnapshot>,
|
||||||
/// Session whose quick-actions menu is open.
|
/// Session whose quick-actions menu is open.
|
||||||
session_menu: Option<i32>,
|
session_menu: Option<i32>,
|
||||||
/// Session waiting for explicit confirmation before deletion.
|
/// Session waiting for explicit confirmation before deletion.
|
||||||
@@ -146,6 +148,58 @@ pub(crate) struct App {
|
|||||||
manual_compaction_queued: bool,
|
manual_compaction_queued: bool,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
skip_compaction_once: bool,
|
skip_compaction_once: bool,
|
||||||
|
quit_confirmation: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
struct ChatSnapshot {
|
||||||
|
selected_project: Option<i32>,
|
||||||
|
selected_session: Option<i32>,
|
||||||
|
composer: String,
|
||||||
|
queued_inputs: VecDeque<String>,
|
||||||
|
conversation: Vec<ChatMessage>,
|
||||||
|
chat_follow_tail: bool,
|
||||||
|
active_turn: Option<generation::TurnSummary>,
|
||||||
|
a2ui: crate::a2ui::Store,
|
||||||
|
a2ui_history: Vec<crate::a2ui::Store>,
|
||||||
|
a2ui_history_index: Option<usize>,
|
||||||
|
a2ui_tabs: HashMap<(String, String), usize>,
|
||||||
|
a2ui_modals: HashSet<(String, String)>,
|
||||||
|
a2ui_editors: HashMap<(String, String, String), text_editor::Content>,
|
||||||
|
a2ui_markdown: HashMap<(String, String, String), markdown::Content>,
|
||||||
|
a2ui_choice_filters: HashMap<(String, String, String), String>,
|
||||||
|
a2ui_images: HashMap<String, iced::widget::image::Handle>,
|
||||||
|
a2ui_image_requests: HashSet<String>,
|
||||||
|
a2ui_image_loading: bool,
|
||||||
|
pending_a2ui_dismissal: Option<String>,
|
||||||
|
a2ui_auto_switch_pending: bool,
|
||||||
|
generating: bool,
|
||||||
|
context_used: u32,
|
||||||
|
context_limit: u32,
|
||||||
|
tokens_per_second: Option<f32>,
|
||||||
|
detail_tab: DetailTab,
|
||||||
|
active_generation: Option<ActiveGeneration>,
|
||||||
|
active_compaction: Option<generation::CompactionRequest>,
|
||||||
|
active_tool_check: Option<generation::ToolResultCheck>,
|
||||||
|
agent_tools: Option<(i32, Arc<Mutex<crate::agent::Tools>>)>,
|
||||||
|
active_tools: Option<crate::agent::ActiveTools>,
|
||||||
|
tool_cards: Vec<crate::agent::ToolCard>,
|
||||||
|
pending_tool_approval: Option<(crate::agent::ApprovalPrompt, std::sync::mpsc::Sender<bool>)>,
|
||||||
|
active_titling: Option<generation::TitleRequest>,
|
||||||
|
error: Option<String>,
|
||||||
|
activity: Option<String>,
|
||||||
|
context_notice: Option<String>,
|
||||||
|
stop_requested: bool,
|
||||||
|
system_prompt_seen_at: u32,
|
||||||
|
manual_compaction_queued: bool,
|
||||||
|
skip_compaction_once: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
impl ChatSnapshot {
|
||||||
|
fn needs_poll(&self) -> bool {
|
||||||
|
chat_needs_poll(self.generating, self.active_titling.is_some())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
@@ -229,10 +283,14 @@ pub(crate) enum Message {
|
|||||||
HelpOpened(window::Id),
|
HelpOpened(window::Id),
|
||||||
NewChat,
|
NewChat,
|
||||||
ExportChat,
|
ExportChat,
|
||||||
ExportChatPicked(Option<PathBuf>),
|
ExportChatPicked(Option<PathBuf>, String),
|
||||||
ModelManagerOpened(window::Id),
|
ModelManagerOpened(window::Id),
|
||||||
WindowOpened(window::Id),
|
WindowOpened(window::Id),
|
||||||
|
WindowCloseRequested(window::Id),
|
||||||
WindowClosed(window::Id),
|
WindowClosed(window::Id),
|
||||||
|
RequestQuit,
|
||||||
|
ConfirmQuit,
|
||||||
|
CancelQuit,
|
||||||
Escape(window::Id),
|
Escape(window::Id),
|
||||||
DismissPanel,
|
DismissPanel,
|
||||||
/// Tab and shift-tab: iced leaves the key to the application, so the fields
|
/// Tab and shift-tab: iced leaves the key to the application, so the fields
|
||||||
@@ -286,7 +344,7 @@ pub(crate) enum Message {
|
|||||||
A2uiAction(String, String, Option<String>),
|
A2uiAction(String, String, Option<String>),
|
||||||
A2uiSelectTab(String, String, usize),
|
A2uiSelectTab(String, String, usize),
|
||||||
A2uiToggleModal(String, String),
|
A2uiToggleModal(String, String),
|
||||||
A2uiImageLoaded(String, Result<Vec<u8>, String>),
|
A2uiImageLoaded(Option<i32>, String, Result<Vec<u8>, String>),
|
||||||
A2uiPlayMedia(String, String, bool),
|
A2uiPlayMedia(String, String, bool),
|
||||||
RequestA2uiDismiss(String),
|
RequestA2uiDismiss(String),
|
||||||
ConfirmA2uiDismiss,
|
ConfirmA2uiDismiss,
|
||||||
@@ -398,6 +456,8 @@ impl App {
|
|||||||
selected_project: last_project,
|
selected_project: last_project,
|
||||||
selected_session: None,
|
selected_session: None,
|
||||||
drafts,
|
drafts,
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
background_chats: HashMap::new(),
|
||||||
session_menu: None,
|
session_menu: None,
|
||||||
pending_session_delete: None,
|
pending_session_delete: None,
|
||||||
session_rename: None,
|
session_rename: None,
|
||||||
@@ -480,6 +540,7 @@ impl App {
|
|||||||
manual_compaction_queued: false,
|
manual_compaction_queued: false,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
skip_compaction_once: false,
|
skip_compaction_once: false,
|
||||||
|
quit_confirmation: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(error) => Self::failed(
|
Err(error) => Self::failed(
|
||||||
@@ -527,6 +588,8 @@ impl App {
|
|||||||
selected_project: None,
|
selected_project: None,
|
||||||
selected_session: None,
|
selected_session: None,
|
||||||
drafts: HashMap::new(),
|
drafts: HashMap::new(),
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
background_chats: HashMap::new(),
|
||||||
session_menu: None,
|
session_menu: None,
|
||||||
pending_session_delete: None,
|
pending_session_delete: None,
|
||||||
session_rename: None,
|
session_rename: None,
|
||||||
@@ -601,9 +664,180 @@ impl App {
|
|||||||
manual_compaction_queued: false,
|
manual_compaction_queued: false,
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
skip_compaction_once: false,
|
skip_compaction_once: false,
|
||||||
|
quit_confirmation: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn take_chat_snapshot(&mut self) -> ChatSnapshot {
|
||||||
|
ChatSnapshot {
|
||||||
|
selected_project: self.selected_project.take(),
|
||||||
|
selected_session: self.selected_session.take(),
|
||||||
|
composer: std::mem::take(&mut self.composer),
|
||||||
|
queued_inputs: std::mem::take(&mut self.queued_inputs),
|
||||||
|
conversation: std::mem::take(&mut self.conversation),
|
||||||
|
chat_follow_tail: std::mem::replace(&mut self.chat_follow_tail, true),
|
||||||
|
active_turn: self.active_turn.take(),
|
||||||
|
a2ui: std::mem::take(&mut self.a2ui),
|
||||||
|
a2ui_history: std::mem::take(&mut self.a2ui_history),
|
||||||
|
a2ui_history_index: self.a2ui_history_index.take(),
|
||||||
|
a2ui_tabs: std::mem::take(&mut self.a2ui_tabs),
|
||||||
|
a2ui_modals: std::mem::take(&mut self.a2ui_modals),
|
||||||
|
a2ui_editors: std::mem::take(&mut self.a2ui_editors),
|
||||||
|
a2ui_markdown: std::mem::take(&mut self.a2ui_markdown),
|
||||||
|
a2ui_choice_filters: std::mem::take(&mut self.a2ui_choice_filters),
|
||||||
|
a2ui_images: std::mem::take(&mut self.a2ui_images),
|
||||||
|
a2ui_image_requests: std::mem::take(&mut self.a2ui_image_requests),
|
||||||
|
a2ui_image_loading: std::mem::take(&mut self.a2ui_image_loading),
|
||||||
|
pending_a2ui_dismissal: self.pending_a2ui_dismissal.take(),
|
||||||
|
a2ui_auto_switch_pending: std::mem::take(&mut self.a2ui_auto_switch_pending),
|
||||||
|
generating: std::mem::take(&mut self.generating),
|
||||||
|
context_used: std::mem::take(&mut self.context_used),
|
||||||
|
context_limit: std::mem::replace(
|
||||||
|
&mut self.context_limit,
|
||||||
|
self.config.generation.context_tokens.max(0) as u32,
|
||||||
|
),
|
||||||
|
tokens_per_second: self.tokens_per_second.take(),
|
||||||
|
detail_tab: std::mem::take(&mut self.detail_tab),
|
||||||
|
active_generation: self.active_generation.take(),
|
||||||
|
active_compaction: self.active_compaction.take(),
|
||||||
|
active_tool_check: self.active_tool_check.take(),
|
||||||
|
agent_tools: self.agent_tools.take(),
|
||||||
|
active_tools: self.active_tools.take(),
|
||||||
|
tool_cards: std::mem::take(&mut self.tool_cards),
|
||||||
|
pending_tool_approval: self.pending_tool_approval.take(),
|
||||||
|
active_titling: self.active_titling.take(),
|
||||||
|
error: self.error.take(),
|
||||||
|
activity: self.activity.take(),
|
||||||
|
context_notice: self.context_notice.take(),
|
||||||
|
stop_requested: std::mem::take(&mut self.stop_requested),
|
||||||
|
system_prompt_seen_at: std::mem::take(&mut self.system_prompt_seen_at),
|
||||||
|
manual_compaction_queued: std::mem::take(&mut self.manual_compaction_queued),
|
||||||
|
skip_compaction_once: std::mem::take(&mut self.skip_compaction_once),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn restore_chat_snapshot(&mut self, snapshot: ChatSnapshot) {
|
||||||
|
self.selected_project = snapshot.selected_project;
|
||||||
|
self.selected_session = snapshot.selected_session;
|
||||||
|
self.composer = snapshot.composer;
|
||||||
|
self.queued_inputs = snapshot.queued_inputs;
|
||||||
|
self.conversation = snapshot.conversation;
|
||||||
|
self.chat_follow_tail = snapshot.chat_follow_tail;
|
||||||
|
self.active_turn = snapshot.active_turn;
|
||||||
|
self.a2ui = snapshot.a2ui;
|
||||||
|
self.a2ui_history = snapshot.a2ui_history;
|
||||||
|
self.a2ui_history_index = snapshot.a2ui_history_index;
|
||||||
|
self.a2ui_tabs = snapshot.a2ui_tabs;
|
||||||
|
self.a2ui_modals = snapshot.a2ui_modals;
|
||||||
|
self.a2ui_editors = snapshot.a2ui_editors;
|
||||||
|
self.a2ui_markdown = snapshot.a2ui_markdown;
|
||||||
|
self.a2ui_choice_filters = snapshot.a2ui_choice_filters;
|
||||||
|
self.a2ui_images = snapshot.a2ui_images;
|
||||||
|
self.a2ui_image_requests = snapshot.a2ui_image_requests;
|
||||||
|
self.a2ui_image_loading = snapshot.a2ui_image_loading;
|
||||||
|
self.pending_a2ui_dismissal = snapshot.pending_a2ui_dismissal;
|
||||||
|
self.a2ui_auto_switch_pending = snapshot.a2ui_auto_switch_pending;
|
||||||
|
self.generating = snapshot.generating;
|
||||||
|
self.context_used = snapshot.context_used;
|
||||||
|
self.context_limit = snapshot.context_limit;
|
||||||
|
self.tokens_per_second = snapshot.tokens_per_second;
|
||||||
|
self.detail_tab = snapshot.detail_tab;
|
||||||
|
self.active_generation = snapshot.active_generation;
|
||||||
|
self.active_compaction = snapshot.active_compaction;
|
||||||
|
self.active_tool_check = snapshot.active_tool_check;
|
||||||
|
self.agent_tools = snapshot.agent_tools;
|
||||||
|
self.active_tools = snapshot.active_tools;
|
||||||
|
self.tool_cards = snapshot.tool_cards;
|
||||||
|
self.pending_tool_approval = snapshot.pending_tool_approval;
|
||||||
|
self.active_titling = snapshot.active_titling;
|
||||||
|
self.error = snapshot.error;
|
||||||
|
self.activity = snapshot.activity;
|
||||||
|
self.context_notice = snapshot.context_notice;
|
||||||
|
self.stop_requested = snapshot.stop_requested;
|
||||||
|
self.system_prompt_seen_at = snapshot.system_prompt_seen_at;
|
||||||
|
self.manual_compaction_queued = snapshot.manual_compaction_queued;
|
||||||
|
self.skip_compaction_once = snapshot.skip_compaction_once;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub(super) fn leave_current_chat(&mut self) {
|
||||||
|
let snapshot = self.take_chat_snapshot();
|
||||||
|
if let Some(session_id) = snapshot.selected_session
|
||||||
|
&& (snapshot.needs_poll() || snapshot.a2ui_image_loading)
|
||||||
|
{
|
||||||
|
self.background_chats.insert(session_id, snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn restore_background_chat(&mut self, session_id: i32) -> bool {
|
||||||
|
let Some(mut snapshot) = self.background_chats.remove(&session_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
snapshot.chat_follow_tail = true;
|
||||||
|
self.restore_chat_snapshot(snapshot);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn poll_background_chats(&mut self) {
|
||||||
|
let session_ids = self
|
||||||
|
.background_chats
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(session_id, chat)| chat.needs_poll().then_some(*session_id))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for session_id in session_ids {
|
||||||
|
let foreground = self.take_chat_snapshot();
|
||||||
|
let Some(background) = self.background_chats.remove(&session_id) else {
|
||||||
|
self.restore_chat_snapshot(foreground);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
self.restore_chat_snapshot(background);
|
||||||
|
self.poll_titling();
|
||||||
|
self.poll_generation();
|
||||||
|
let background = self.take_chat_snapshot();
|
||||||
|
self.background_chats.insert(session_id, background);
|
||||||
|
self.restore_chat_snapshot(foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub(super) fn session_is_active(&self, session_id: i32) -> bool {
|
||||||
|
(self.selected_session == Some(session_id) && self.generating)
|
||||||
|
|| self
|
||||||
|
.background_chats
|
||||||
|
.get(&session_id)
|
||||||
|
.is_some_and(|chat| chat.generating)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
pub(super) fn session_is_active(&self, _session_id: i32) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_chat_count(&self) -> usize {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
return active_chat_total(
|
||||||
|
self.generating,
|
||||||
|
self.background_chats.values().map(|chat| chat.generating),
|
||||||
|
);
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
active_chat_total(self.generating, std::iter::empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project_has_active_chat(&self, project_id: i32) -> bool {
|
||||||
|
self.projects
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.project.id == project_id)
|
||||||
|
.is_some_and(|item| {
|
||||||
|
item.sessions
|
||||||
|
.iter()
|
||||||
|
.any(|session| self.session_is_active(session.id))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn update(&mut self, message: Message) -> Task<Message> {
|
pub(crate) fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
match message {
|
match message {
|
||||||
Message::Noop => {
|
Message::Noop => {
|
||||||
@@ -645,6 +879,7 @@ impl App {
|
|||||||
return Task::none();
|
return Task::none();
|
||||||
};
|
};
|
||||||
let file_name = format!("{}.md", export_file_stem(&title));
|
let file_name = format!("{}.md", export_file_stem(&title));
|
||||||
|
let content = export_markdown(&title, &self.conversation);
|
||||||
return Task::perform(
|
return Task::perform(
|
||||||
async move {
|
async move {
|
||||||
AsyncFileDialog::new()
|
AsyncFileDialog::new()
|
||||||
@@ -655,13 +890,12 @@ impl App {
|
|||||||
.await
|
.await
|
||||||
.map(|file| file.path().to_path_buf())
|
.map(|file| file.path().to_path_buf())
|
||||||
},
|
},
|
||||||
Message::ExportChatPicked,
|
move |path| Message::ExportChatPicked(path, content),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Message::ExportChatPicked(path) => {
|
Message::ExportChatPicked(path, content) => {
|
||||||
if let Some(path) = path
|
if let Some(path) = path
|
||||||
&& let Some(title) = self.active_chat_title()
|
&& let Err(error) = fs::write(&path, content)
|
||||||
&& let Err(error) = fs::write(&path, export_markdown(title, &self.conversation))
|
|
||||||
{
|
{
|
||||||
self.error = Some(format!("Could not export the chat: {error}"));
|
self.error = Some(format!("Could not export the chat: {error}"));
|
||||||
}
|
}
|
||||||
@@ -688,10 +922,13 @@ impl App {
|
|||||||
return focus_composer();
|
return focus_composer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::WindowClosed(id) => {
|
Message::WindowCloseRequested(id) => {
|
||||||
if id == self.main_window {
|
if id == self.main_window {
|
||||||
return iced::exit();
|
return self.update(Message::RequestQuit);
|
||||||
}
|
}
|
||||||
|
return window::close(id);
|
||||||
|
}
|
||||||
|
Message::WindowClosed(id) => {
|
||||||
if self.model_manager_window == Some(id) {
|
if self.model_manager_window == Some(id) {
|
||||||
self.model_manager_window = None;
|
self.model_manager_window = None;
|
||||||
self.pending_model_delete = None;
|
self.pending_model_delete = None;
|
||||||
@@ -704,6 +941,14 @@ impl App {
|
|||||||
self.help_window = None;
|
self.help_window = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Message::RequestQuit => {
|
||||||
|
if self.active_chat_count() == 0 {
|
||||||
|
return iced::exit();
|
||||||
|
}
|
||||||
|
self.quit_confirmation = true;
|
||||||
|
}
|
||||||
|
Message::ConfirmQuit => return iced::exit(),
|
||||||
|
Message::CancelQuit => self.quit_confirmation = false,
|
||||||
Message::Escape(id) => {
|
Message::Escape(id) => {
|
||||||
if self.preferences_window == Some(id) {
|
if self.preferences_window == Some(id) {
|
||||||
return self.update(Message::ClosePreferences);
|
return self.update(Message::ClosePreferences);
|
||||||
@@ -713,7 +958,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::DismissPanel => {
|
Message::DismissPanel => {
|
||||||
if self.pending_session_delete.is_some() {
|
if self.quit_confirmation {
|
||||||
|
self.quit_confirmation = false;
|
||||||
|
} else if self.pending_session_delete.is_some() {
|
||||||
self.pending_session_delete = None;
|
self.pending_session_delete = None;
|
||||||
} else if self.pending_a2ui_dismissal.is_some() {
|
} else if self.pending_a2ui_dismissal.is_some() {
|
||||||
self.pending_a2ui_dismissal = None;
|
self.pending_a2ui_dismissal = None;
|
||||||
@@ -1091,7 +1338,29 @@ impl App {
|
|||||||
self.a2ui_modals.insert(key);
|
self.a2ui_modals.insert(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::A2uiImageLoaded(url, result) => {
|
Message::A2uiImageLoaded(session_id, url, result) => {
|
||||||
|
if session_id != self.selected_session {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
let foreground = self.take_chat_snapshot();
|
||||||
|
if let Some(background) = session_id
|
||||||
|
.and_then(|session_id| self.background_chats.remove(&session_id))
|
||||||
|
{
|
||||||
|
self.restore_chat_snapshot(background);
|
||||||
|
let task =
|
||||||
|
self.update(Message::A2uiImageLoaded(session_id, url, result));
|
||||||
|
let background = self.take_chat_snapshot();
|
||||||
|
self.background_chats.insert(
|
||||||
|
session_id.expect("background chat has a session"),
|
||||||
|
background,
|
||||||
|
);
|
||||||
|
self.restore_chat_snapshot(foreground);
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
self.restore_chat_snapshot(foreground);
|
||||||
|
}
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
self.a2ui_image_loading = false;
|
self.a2ui_image_loading = false;
|
||||||
match result {
|
match result {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
@@ -1245,6 +1514,8 @@ impl App {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
self.poll_titling();
|
self.poll_titling();
|
||||||
self.poll_generation();
|
self.poll_generation();
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
self.poll_background_chats();
|
||||||
let images = self.load_next_a2ui_image();
|
let images = self.load_next_a2ui_image();
|
||||||
return images;
|
return images;
|
||||||
}
|
}
|
||||||
@@ -1301,7 +1572,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::DeleteProject(project_id) => {
|
Message::DeleteProject(project_id) => {
|
||||||
if self.generating && self.selected_project == Some(project_id) {
|
if self.project_has_active_chat(project_id) {
|
||||||
self.error =
|
self.error =
|
||||||
Some("Stop the active generation before deleting its project.".into());
|
Some("Stop the active generation before deleting its project.".into());
|
||||||
return Task::none();
|
return Task::none();
|
||||||
@@ -1322,6 +1593,8 @@ impl App {
|
|||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
for session_id in checkpoint_ids {
|
for session_id in checkpoint_ids {
|
||||||
let _ = fs::remove_file(session_checkpoint_path(session_id));
|
let _ = fs::remove_file(session_checkpoint_path(session_id));
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
self.background_chats.remove(&session_id);
|
||||||
}
|
}
|
||||||
self.drafts.remove(&project_id);
|
self.drafts.remove(&project_id);
|
||||||
if self.config.interface.last_project_id == Some(project_id) {
|
if self.config.interface.last_project_id == Some(project_id) {
|
||||||
@@ -1425,7 +1698,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
Message::RebuildSessionContext(session_id) => {
|
Message::RebuildSessionContext(session_id) => {
|
||||||
self.session_menu = None;
|
self.session_menu = None;
|
||||||
if self.generating && self.selected_session == Some(session_id) {
|
if self.session_is_active(session_id) {
|
||||||
self.error =
|
self.error =
|
||||||
Some("Stop the active generation before rebuilding context.".into());
|
Some("Stop the active generation before rebuilding context.".into());
|
||||||
} else {
|
} else {
|
||||||
@@ -1461,18 +1734,24 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::SelectSession(project_id, session_id) => {
|
Message::SelectSession(project_id, session_id) => {
|
||||||
if self.generating && self.selected_session != Some(session_id) {
|
|
||||||
self.error =
|
|
||||||
Some("Stop the active generation before changing sessions.".into());
|
|
||||||
return Task::none();
|
|
||||||
}
|
|
||||||
if self.selected_session == Some(session_id) {
|
if self.selected_session == Some(session_id) {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
self.stop_agent_jobs();
|
{
|
||||||
#[cfg(target_os = "macos")]
|
self.leave_current_chat();
|
||||||
self.tool_cards.clear();
|
if self.restore_background_chat(session_id) {
|
||||||
|
self.chat_follow_tail = true;
|
||||||
|
self.remember_project(project_id);
|
||||||
|
if let Some(database) = &mut self.database
|
||||||
|
&& let Err(error) = database.touch_session(session_id)
|
||||||
|
{
|
||||||
|
self.error = Some(format!("Could not update the session: {error}"));
|
||||||
|
}
|
||||||
|
self.reload_projects();
|
||||||
|
return Task::batch([scroll_chat_to_end(), self.load_next_a2ui_image()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
let saved_context = self
|
let saved_context = self
|
||||||
.projects
|
.projects
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1542,7 +1821,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::RequestDeleteSession(session_id) => {
|
Message::RequestDeleteSession(session_id) => {
|
||||||
if self.generating && self.selected_session == Some(session_id) {
|
if self.session_is_active(session_id) {
|
||||||
self.error =
|
self.error =
|
||||||
Some("Stop the active generation before deleting its session.".into());
|
Some("Stop the active generation before deleting its session.".into());
|
||||||
return Task::none();
|
return Task::none();
|
||||||
@@ -1563,7 +1842,7 @@ impl App {
|
|||||||
let Some(session_id) = self.pending_session_delete.take() else {
|
let Some(session_id) = self.pending_session_delete.take() else {
|
||||||
return Task::none();
|
return Task::none();
|
||||||
};
|
};
|
||||||
if self.generating && self.selected_session == Some(session_id) {
|
if self.session_is_active(session_id) {
|
||||||
self.error =
|
self.error =
|
||||||
Some("Stop the active generation before deleting its session.".into());
|
Some("Stop the active generation before deleting its session.".into());
|
||||||
return Task::none();
|
return Task::none();
|
||||||
@@ -1572,6 +1851,8 @@ impl App {
|
|||||||
match database.delete_session(session_id) {
|
match database.delete_session(session_id) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
let _ = fs::remove_file(session_checkpoint_path(session_id));
|
let _ = fs::remove_file(session_checkpoint_path(session_id));
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
self.background_chats.remove(&session_id);
|
||||||
self.finish_cache_change();
|
self.finish_cache_change();
|
||||||
if self.session_menu == Some(session_id) {
|
if self.session_menu == Some(session_id) {
|
||||||
self.session_menu = None;
|
self.session_menu = None;
|
||||||
@@ -1622,7 +1903,7 @@ impl App {
|
|||||||
)
|
)
|
||||||
.then_some(Message::Escape(id))
|
.then_some(Message::Escape(id))
|
||||||
}),
|
}),
|
||||||
window::close_requests().map(Message::WindowClosed),
|
window::close_requests().map(Message::WindowCloseRequested),
|
||||||
window::close_events().map(Message::WindowClosed),
|
window::close_events().map(Message::WindowClosed),
|
||||||
];
|
];
|
||||||
subscriptions
|
subscriptions
|
||||||
@@ -1638,6 +1919,7 @@ impl App {
|
|||||||
Some(crate::native_menu::NativeMenuEvent::ExportChat) => Message::ExportChat,
|
Some(crate::native_menu::NativeMenuEvent::ExportChat) => Message::ExportChat,
|
||||||
Some(crate::native_menu::NativeMenuEvent::ToggleSidebar) => Message::ToggleSidebar,
|
Some(crate::native_menu::NativeMenuEvent::ToggleSidebar) => Message::ToggleSidebar,
|
||||||
Some(crate::native_menu::NativeMenuEvent::Help) => Message::OpenHelp,
|
Some(crate::native_menu::NativeMenuEvent::Help) => Message::OpenHelp,
|
||||||
|
Some(crate::native_menu::NativeMenuEvent::Quit) => Message::RequestQuit,
|
||||||
Some(crate::native_menu::NativeMenuEvent::Edit(command)) => {
|
Some(crate::native_menu::NativeMenuEvent::Edit(command)) => {
|
||||||
Message::NativeEdit(command)
|
Message::NativeEdit(command)
|
||||||
}
|
}
|
||||||
@@ -1653,7 +1935,11 @@ impl App {
|
|||||||
let titling = self.active_titling.is_some();
|
let titling = self.active_titling.is_some();
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
let titling = false;
|
let titling = false;
|
||||||
if self.generating || titling {
|
#[cfg(target_os = "macos")]
|
||||||
|
let background_polling = self.background_chats.values().any(ChatSnapshot::needs_poll);
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
let background_polling = false;
|
||||||
|
if self.generating || titling || background_polling {
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
iced::time::every(Duration::from_millis(50)).map(|_| Message::GenerationTick),
|
iced::time::every(Duration::from_millis(50)).map(|_| Message::GenerationTick),
|
||||||
);
|
);
|
||||||
@@ -1824,7 +2110,7 @@ impl App {
|
|||||||
.lock()
|
.lock()
|
||||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
menu.update(
|
menu.update(
|
||||||
self.selected_project.is_some() && !self.generating,
|
self.selected_project.is_some(),
|
||||||
self.active_chat_title().is_some(),
|
self.active_chat_title().is_some(),
|
||||||
!self.config.interface.sidebar_collapsed,
|
!self.config.interface.sidebar_collapsed,
|
||||||
edit,
|
edit,
|
||||||
@@ -1893,6 +2179,7 @@ fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option<Messag
|
|||||||
}
|
}
|
||||||
keyboard::Key::Character("b") if modifiers.command() => Some(Message::ToggleSidebar),
|
keyboard::Key::Character("b") if modifiers.command() => Some(Message::ToggleSidebar),
|
||||||
keyboard::Key::Character("n") if modifiers.command() => Some(Message::NewChat),
|
keyboard::Key::Character("n") if modifiers.command() => Some(Message::NewChat),
|
||||||
|
keyboard::Key::Character("q") if modifiers.command() => Some(Message::RequestQuit),
|
||||||
keyboard::Key::Named(keyboard::key::Named::Tab) => Some(if modifiers.shift() {
|
keyboard::Key::Named(keyboard::key::Named::Tab) => Some(if modifiers.shift() {
|
||||||
Message::FocusPrevious
|
Message::FocusPrevious
|
||||||
} else {
|
} else {
|
||||||
@@ -1902,6 +2189,14 @@ fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option<Messag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn chat_needs_poll(generating: bool, titling: bool) -> bool {
|
||||||
|
generating || titling
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_chat_total(foreground: bool, background: impl IntoIterator<Item = bool>) -> usize {
|
||||||
|
usize::from(foreground) + background.into_iter().filter(|active| *active).count()
|
||||||
|
}
|
||||||
|
|
||||||
fn export_file_stem(title: &str) -> String {
|
fn export_file_stem(title: &str) -> String {
|
||||||
let stem = title
|
let stem = title
|
||||||
.chars()
|
.chars()
|
||||||
@@ -2242,6 +2537,13 @@ mod tests {
|
|||||||
keyboard::Modifiers::COMMAND | keyboard::Modifiers::SHIFT,
|
keyboard::Modifiers::COMMAND | keyboard::Modifiers::SHIFT,
|
||||||
);
|
);
|
||||||
assert!(matches!(message, Some(Message::OpenModelManager)));
|
assert!(matches!(message, Some(Message::OpenModelManager)));
|
||||||
|
assert!(matches!(
|
||||||
|
shortcut(
|
||||||
|
keyboard::Key::Character("q".into()),
|
||||||
|
keyboard::Modifiers::COMMAND
|
||||||
|
),
|
||||||
|
Some(Message::RequestQuit)
|
||||||
|
));
|
||||||
let tab = keyboard::Key::Named(keyboard::key::Named::Tab);
|
let tab = keyboard::Key::Named(keyboard::key::Named::Tab);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
shortcut(tab.clone(), keyboard::Modifiers::empty()),
|
shortcut(tab.clone(), keyboard::Modifiers::empty()),
|
||||||
@@ -2256,6 +2558,15 @@ mod tests {
|
|||||||
assert!(!ModelChoice::Glm52.supports_dspark());
|
assert!(!ModelChoice::Glm52.supports_dspark());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn background_activity_drives_polling_indicators_and_quit_confirmation() {
|
||||||
|
assert!(chat_needs_poll(true, false));
|
||||||
|
assert!(chat_needs_poll(false, true));
|
||||||
|
assert!(!chat_needs_poll(false, false));
|
||||||
|
assert_eq!(active_chat_total(false, [true, false, true]), 2);
|
||||||
|
assert_eq!(active_chat_total(true, [true, false, true]), 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tab_scrolls_a_field_back_into_the_preferences_viewport() {
|
fn tab_scrolls_a_field_back_into_the_preferences_viewport() {
|
||||||
let viewport = iced::Rectangle {
|
let viewport = iced::Rectangle {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ impl App {
|
|||||||
self.a2ui_image_requests.insert(url.clone());
|
self.a2ui_image_requests.insert(url.clone());
|
||||||
self.a2ui_image_loading = true;
|
self.a2ui_image_loading = true;
|
||||||
let request_url = url.clone();
|
let request_url = url.clone();
|
||||||
|
let session_id = self.selected_session;
|
||||||
Task::perform(
|
Task::perform(
|
||||||
async move {
|
async move {
|
||||||
let mut response = ureq::get(&request_url)
|
let mut response = ureq::get(&request_url)
|
||||||
@@ -48,7 +49,7 @@ impl App {
|
|||||||
.read_to_vec()
|
.read_to_vec()
|
||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
},
|
},
|
||||||
move |result| Message::A2uiImageLoaded(url, result),
|
move |result| Message::A2uiImageLoaded(session_id, url, result),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,17 +160,11 @@ impl App {
|
|||||||
/// Opens an unsaved session on a project and selects it. Nothing reaches the
|
/// Opens an unsaved session on a project and selects it. Nothing reaches the
|
||||||
/// database until the first chat turn is stored by [`App::persist_session`].
|
/// database until the first chat turn is stored by [`App::persist_session`].
|
||||||
pub(super) fn create_session(&mut self, project_id: i32) {
|
pub(super) fn create_session(&mut self, project_id: i32) {
|
||||||
if self.generating {
|
|
||||||
self.error = Some("Stop the active generation before creating a session.".into());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if self.database.is_none() {
|
if self.database.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
self.stop_agent_jobs();
|
self.leave_current_chat();
|
||||||
#[cfg(target_os = "macos")]
|
|
||||||
self.tool_cards.clear();
|
|
||||||
let title = draft_title(&self.projects, project_id);
|
let title = draft_title(&self.projects, project_id);
|
||||||
self.drafts.entry(project_id).or_insert(title);
|
self.drafts.entry(project_id).or_insert(title);
|
||||||
self.remember_project(project_id);
|
self.remember_project(project_id);
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ impl App {
|
|||||||
/// Whether a dialog covers the window. Focus moves through the whole widget
|
/// Whether a dialog covers the window. Focus moves through the whole widget
|
||||||
/// tree, so the layers below have to stay out of the dialog's field order.
|
/// tree, so the layers below have to stay out of the dialog's field order.
|
||||||
pub(super) fn modal_open(&self) -> bool {
|
pub(super) fn modal_open(&self) -> bool {
|
||||||
self.pending_project_path.is_some()
|
self.quit_confirmation
|
||||||
|
|| self.pending_project_path.is_some()
|
||||||
|| self.pending_session_delete.is_some()
|
|| self.pending_session_delete.is_some()
|
||||||
|| self.session_rename.is_some()
|
|| self.session_rename.is_some()
|
||||||
|| self.menu_session().is_some()
|
|| self.menu_session().is_some()
|
||||||
@@ -161,7 +162,9 @@ impl App {
|
|||||||
let mut layers = vec![content];
|
let mut layers = vec![content];
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
if let Some((prompt, _)) = &self.pending_tool_approval {
|
if self.quit_confirmation {
|
||||||
|
layers.push(self.quit_confirmation_panel());
|
||||||
|
} else if let Some((prompt, _)) = &self.pending_tool_approval {
|
||||||
layers.push(self.tool_approval_panel(prompt));
|
layers.push(self.tool_approval_panel(prompt));
|
||||||
} else if let Some(path) = &self.pending_project_path {
|
} else if let Some(path) = &self.pending_project_path {
|
||||||
layers.push(self.project_dialog(path));
|
layers.push(self.project_dialog(path));
|
||||||
@@ -177,7 +180,9 @@ impl App {
|
|||||||
layers.push(panel);
|
layers.push(panel);
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(target_os = "macos"))]
|
||||||
if let Some(path) = &self.pending_project_path {
|
if self.quit_confirmation {
|
||||||
|
layers.push(self.quit_confirmation_panel());
|
||||||
|
} else if let Some(path) = &self.pending_project_path {
|
||||||
layers.push(self.project_dialog(path));
|
layers.push(self.project_dialog(path));
|
||||||
} else if let Some((_, title)) = &self.session_rename {
|
} else if let Some((_, title)) = &self.session_rename {
|
||||||
layers.push(self.rename_dialog(title));
|
layers.push(self.rename_dialog(title));
|
||||||
@@ -197,6 +202,38 @@ impl App {
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn quit_confirmation_panel(&self) -> Element<'_, Message> {
|
||||||
|
let active = self.active_chat_count();
|
||||||
|
let dialog = container(
|
||||||
|
column![
|
||||||
|
text("Quit while chats are active?").size(22),
|
||||||
|
text(format!(
|
||||||
|
"{active} active chat{} will be stopped.",
|
||||||
|
if active == 1 { "" } else { "s" }
|
||||||
|
))
|
||||||
|
.size(13),
|
||||||
|
row![
|
||||||
|
Space::new().width(Length::Fill),
|
||||||
|
action_button("Cancel").on_press(Message::CancelQuit),
|
||||||
|
danger_button("Quit").on_press(Message::ConfirmQuit),
|
||||||
|
]
|
||||||
|
.spacing(8),
|
||||||
|
]
|
||||||
|
.spacing(12),
|
||||||
|
)
|
||||||
|
.padding(22)
|
||||||
|
.width(460)
|
||||||
|
.style(overview_style);
|
||||||
|
opaque(
|
||||||
|
container(dialog)
|
||||||
|
.center_x(Length::Fill)
|
||||||
|
.center_y(Length::Fill)
|
||||||
|
.style(|_| {
|
||||||
|
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn tool_approval_panel<'a>(
|
fn tool_approval_panel<'a>(
|
||||||
&'a self,
|
&'a self,
|
||||||
@@ -408,6 +445,9 @@ impl App {
|
|||||||
label = label.push(icon(ICON_PIN, 12));
|
label = label.push(icon(ICON_PIN, 12));
|
||||||
}
|
}
|
||||||
label = label.push(text(&session.title).size(13));
|
label = label.push(text(&session.title).size(13));
|
||||||
|
if self.session_is_active(session.id) {
|
||||||
|
label = label.push(text("●").size(10).color(app_theme().palette().success));
|
||||||
|
}
|
||||||
row![
|
row![
|
||||||
Space::new().width(26),
|
Space::new().width(26),
|
||||||
button(label)
|
button(label)
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ fn main() -> iced::Result {
|
|||||||
size: Size::new(1120.0, 720.0),
|
size: Size::new(1120.0, 720.0),
|
||||||
min_size: Some(Size::new(760.0, 480.0)),
|
min_size: Some(Size::new(760.0, 480.0)),
|
||||||
icon: Some(app_icon()),
|
icon: Some(app_icon()),
|
||||||
|
exit_on_close_request: false,
|
||||||
// The title bar is ours: content runs to the top edge and the
|
// The title bar is ours: content runs to the top edge and the
|
||||||
// native traffic lights float over it.
|
// native traffic lights float over it.
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const NEW_CHAT: &str = "new-chat";
|
|||||||
const EXPORT_CHAT: &str = "export-chat";
|
const EXPORT_CHAT: &str = "export-chat";
|
||||||
const TOGGLE_SIDEBAR: &str = "toggle-sidebar";
|
const TOGGLE_SIDEBAR: &str = "toggle-sidebar";
|
||||||
const HELP: &str = "help";
|
const HELP: &str = "help";
|
||||||
|
const QUIT: &str = "quit";
|
||||||
const UNDO: &str = "undo";
|
const UNDO: &str = "undo";
|
||||||
const REDO: &str = "redo";
|
const REDO: &str = "redo";
|
||||||
const CUT: &str = "cut";
|
const CUT: &str = "cut";
|
||||||
@@ -37,6 +38,7 @@ pub(crate) enum NativeMenuEvent {
|
|||||||
ExportChat,
|
ExportChat,
|
||||||
ToggleSidebar,
|
ToggleSidebar,
|
||||||
Help,
|
Help,
|
||||||
|
Quit,
|
||||||
Edit(EditCommand),
|
Edit(EditCommand),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +102,12 @@ pub(crate) fn install() -> Result<NativeMenu, String> {
|
|||||||
let copy = edit_item(COPY, "Copy", Code::KeyC, None);
|
let copy = edit_item(COPY, "Copy", Code::KeyC, None);
|
||||||
let paste = edit_item(PASTE, "Paste", Code::KeyV, None);
|
let paste = edit_item(PASTE, "Paste", Code::KeyV, None);
|
||||||
let select_all = edit_item(SELECT_ALL, "Select All", Code::KeyA, None);
|
let select_all = edit_item(SELECT_ALL, "Select All", Code::KeyA, None);
|
||||||
|
let quit = MenuItem::with_id(
|
||||||
|
QUIT,
|
||||||
|
"Quit DS4Server",
|
||||||
|
true,
|
||||||
|
Some(Accelerator::new(Some(Modifiers::SUPER), Code::KeyQ)),
|
||||||
|
);
|
||||||
|
|
||||||
application
|
application
|
||||||
.append_items(&[
|
.append_items(&[
|
||||||
@@ -113,7 +121,7 @@ pub(crate) fn install() -> Result<NativeMenu, String> {
|
|||||||
&PredefinedMenuItem::hide_others(None),
|
&PredefinedMenuItem::hide_others(None),
|
||||||
&PredefinedMenuItem::show_all(None),
|
&PredefinedMenuItem::show_all(None),
|
||||||
&PredefinedMenuItem::separator(),
|
&PredefinedMenuItem::separator(),
|
||||||
&PredefinedMenuItem::quit(None),
|
&quit,
|
||||||
])
|
])
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
file.append_items(&[
|
file.append_items(&[
|
||||||
@@ -191,6 +199,7 @@ pub(crate) fn next_event() -> Option<NativeMenuEvent> {
|
|||||||
EXPORT_CHAT => NativeMenuEvent::ExportChat,
|
EXPORT_CHAT => NativeMenuEvent::ExportChat,
|
||||||
TOGGLE_SIDEBAR => NativeMenuEvent::ToggleSidebar,
|
TOGGLE_SIDEBAR => NativeMenuEvent::ToggleSidebar,
|
||||||
HELP => NativeMenuEvent::Help,
|
HELP => NativeMenuEvent::Help,
|
||||||
|
QUIT => NativeMenuEvent::Quit,
|
||||||
UNDO => NativeMenuEvent::Edit(EditCommand::Undo),
|
UNDO => NativeMenuEvent::Edit(EditCommand::Undo),
|
||||||
REDO => NativeMenuEvent::Edit(EditCommand::Redo),
|
REDO => NativeMenuEvent::Edit(EditCommand::Redo),
|
||||||
CUT => NativeMenuEvent::Edit(EditCommand::Cut),
|
CUT => NativeMenuEvent::Edit(EditCommand::Cut),
|
||||||
|
|||||||
Reference in New Issue
Block a user