- 프론트: 스트림 onDone 에서 chat.done(title) 한 번. 브라우저에선 no-op - Rust shell/notify: 창 보이고 포커스면 아무것도 안 함. 아니면 Windows 토스트(tauri-plugin-notification) + 트레이 아이콘을 점 찍힌 tray-alert.png 로. 창 Focused(true) 에서 원복 - 토스트는 설치본(msi)에서만 알림센터에 등록돼 보임. dev 에선 트레이 점으로 확인 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
299 lines
10 KiB
Rust
299 lines
10 KiB
Rust
//! 조립부 — .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<String> {
|
|
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::<paste::PasteState>();
|
|
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::<commands::RouteState>()
|
|
.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::<commands::RouteState>();
|
|
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");
|
|
}
|
|
}
|