//! 조립부 — .NET 판의 `App.xaml.cs` 에 대응한다. //! //! CodeAssist 고유 결정(어느 핫키가 뭘 하는지, route 이름, 토글 규칙)은 **전부 여기에만** 있다. //! `shell/` 은 이걸 하나도 모르는 재사용 층이고, `bridge/` 는 프론트 계약만 안다. mod bridge; mod shell; use tauri::{AppHandle, Manager}; use tauri_plugin_window_state::{AppHandleExt, StateFlags}; use bridge::{commands, Push}; use shell::hotkey::HotkeySpec; use shell::paste; use shell::tray::TrayAction; use shell::window; const HOTKEYS: &[HotkeySpec] = &[ HotkeySpec { id: "snap", accelerator: "Ctrl+Alt+W", }, HotkeySpec { id: "snippet", accelerator: "Ctrl+Alt+Q", }, // 캡쳐 4종: 윗줄(A/S) = 새 대화, 아랫줄(Z/X) = 마지막 기존 대화(없으면 새 대화). // 왼쪽(A/Z) = 맨 앞 창 통째, 오른쪽(S/X) = 드래그 영역. HotkeySpec { id: "capture", accelerator: "Ctrl+Alt+A", }, HotkeySpec { id: "capture_area", accelerator: "Ctrl+Alt+S", }, HotkeySpec { id: "capture_last", accelerator: "Ctrl+Alt+Z", }, HotkeySpec { id: "capture_area_last", accelerator: "Ctrl+Alt+X", }, ]; const TOOLTIP: &str = "CodeAssist"; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() // 두 번째 실행은 새 창을 만들지 않고 기존 창을 소환함. .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { let _ = show_palette(app); })) .plugin( tauri_plugin_window_state::Builder::default() .with_state_flags(StateFlags::SIZE | StateFlags::POSITION) .build(), ) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_notification::init()) .manage(commands::RouteState::default()) .manage(paste::PasteState::default()) .manage(window::TemporaryWindowState::default()) .invoke_handler(tauri::generate_handler![ commands::window_hide, commands::window_drag, commands::window_snippet_layout, commands::report_route, commands::paste_code, commands::snippets_list, commands::snippets_create, commands::snippets_update, commands::snippets_delete, commands::snippets_record_use, commands::chat_done, ]) .setup(|app| { let handle = app.handle().clone(); let pinned = window::load_pinned(&handle); let _ = window::set_pinned(&handle, pinned); shell::tray::init(&handle, TOOLTIP, pinned, |app, action| match action { TrayAction::Open => { let _ = show_palette(app); } TrayAction::TogglePin(on) => { let _ = window::set_pinned(app, on); } TrayAction::Quit => { if let Err(error) = window::restore_size(app) { eprintln!("[window] {error}"); } app.exit(0); } })?; for accelerator in shell::hotkey::init(&handle, HOTKEYS, on_hotkey) { // 죽이지 않는다 — 트레이 '열기'로 여전히 쓸 수 있음. eprintln!("[hotkey] {accelerator} 등록 실패 — 다른 앱이 선점함"); } Ok(()) }) .on_window_event(|window, event| { // 창을 다시 보면 트레이 "새 답변" 점 원복. if matches!(event, tauri::WindowEvent::Focused(true)) { shell::notify::seen(window.app_handle()); } if matches!(event, tauri::WindowEvent::CloseRequested { .. }) { if let Err(error) = window::restore_size(window.app_handle()) { eprintln!("[window] {error}"); } // CloseRequested 리스너 순서는 보장되지 않음. 창이 사라지기 전에 // 복원된 실측값을 캐시에도 반영해야 축소 크기가 저장되지 않음. if let Err(error) = window .app_handle() .save_window_state(StateFlags::SIZE | StateFlags::POSITION) { eprintln!("[window-state] {error}"); } } }) .build(tauri::generate_context!()) .expect("Tauri 앱 실행 실패") .run(|app, event| { // window-state 플러그인이 Exit에서 저장하기 전에 원래 크기로 돌림. if matches!(event, tauri::RunEvent::ExitRequested { .. }) { if let Err(error) = window::restore_size(app) { eprintln!("[window] {error}"); } } }); } fn chat_toggle_target(visible: bool, current: &str, last_snap: &str) -> Option { if visible && (current == "/snap" || current.starts_with("/snap/")) { None } else { Some(last_snap.to_string()) } } /// 팔레트를 띄우기 직전에 대상 창 값을 복사해 둔다. 대상이 닫혀도 배지 문자열은 유지됨. fn show_palette(app: &AppHandle) -> tauri::Result<()> { let own_hwnd = window::native_handle(app); let captured = paste::capture_foreground(own_hwnd); let state = app.state::(); let target = match state.lock() { Ok(mut stored) => { if let Some(captured) = captured { *stored = captured; } stored.clone() } Err(_) => paste::PasteTarget::default(), }; window::show(app)?; bridge::push( app, Push::PasteTarget { name: target.name, app: target.app, }, ); Ok(()) } /// 캡쳐 핫키 id 로 목적지 route 결정. `*_last` 는 마지막 기존 대화, 없으면 새 대화. fn capture_target(id: &str, last_session: Option<&str>) -> String { if id.ends_with("_last") { last_session.unwrap_or("/snap/new").to_string() } else { "/snap/new".to_string() } } /// 캡쳐 결과 공통 처리 — 팔레트 소환 → 목적지 대화 화면 → 그 화면 Composer 에 이미지 첨부. fn on_captured(app: AppHandle, target: String, data_url: String) { if let Err(error) = show_palette(&app) { eprintln!("[capture] 창 소환 실패: {error}"); } bridge::push( &app, Push::Navigate { path: target.clone(), }, ); bridge::push( &app, Push::CaptureImage { path: target, data_url, }, ); } /// 표준 전역 단축키가 실행할 CodeAssist 동작을 한 곳에서 정함. fn on_hotkey(app: &AppHandle, id: &str) { match id { "snippet" => { let _ = show_palette(app); bridge::push( app, Push::Navigate { path: "/snippet".into(), }, ); } "capture" | "capture_area" | "capture_last" | "capture_area_last" => { let target = app .state::() .lock() .map(|routes| capture_target(id, routes.last_session.as_deref())) .unwrap_or_else(|_| "/snap/new".to_string()); let deliver = move |app: AppHandle, data_url: String| on_captured(app, target, data_url); if id.starts_with("capture_area") { shell::capture::start(app.clone(), deliver); } else { let own_hwnd = window::native_handle(app); shell::capture::start_foreground_window(app.clone(), own_hwnd, deliver); } } // 챗봇 토글: 이미 챗봇 화면이 떠 있으면 숨기고, 아니면 마지막 챗봇 위치로 데려온다. _ => { let visible = window::is_visible(app); let state = app.state::(); let target = state .lock() .map(|routes| chat_toggle_target(visible, &routes.current, &routes.last_snap)) .unwrap_or_else(|_| Some("/snap".to_string())); match target { None => { let _ = window::hide(app); } Some(path) => { let _ = show_palette(app); bridge::push(app, Push::Navigate { path }); } } } } } #[cfg(test)] mod tests { use super::{capture_target, chat_toggle_target, commands::Routes}; #[test] fn 챗봇_토글은_현재_화면과_창_표시에_따라_결정한다() { assert_eq!(chat_toggle_target(true, "/snap", "/snap/last"), None); assert_eq!( chat_toggle_target(true, "/files", "/snap/last"), Some("/snap/last".to_string()) ); assert_eq!( chat_toggle_target(true, "/snippet", "/snap/last"), Some("/snap/last".to_string()) ); assert_eq!( chat_toggle_target(false, "/snap", "/snap/last"), Some("/snap/last".to_string()) ); } #[test] fn 마지막_기존_대화는_새_대화나_다른_화면으로_가도_유지된다() { let mut routes = Routes::default(); routes.report("/snap/s/session-1"); routes.report("/snap/new"); routes.report("/files"); assert_eq!(routes.current, "/files"); assert_eq!(routes.last_snap, "/snap/new"); assert_eq!(routes.last_session.as_deref(), Some("/snap/s/session-1")); } #[test] fn 캡쳐_핫키에_따라_새_대화나_마지막_기존_대화를_고른다() { assert_eq!(capture_target("capture", Some("/snap/s/last")), "/snap/new"); assert_eq!( capture_target("capture_area", Some("/snap/s/last")), "/snap/new" ); assert_eq!( capture_target("capture_last", Some("/snap/s/last")), "/snap/s/last" ); assert_eq!(capture_target("capture_area_last", None), "/snap/new"); } }