Skip to main content

atmos/apps/drive/
mod.rs

1//! Google Drive ネイティブクライアント(REST 経由)。
2//!
3//! ブラウザで drive.google.com を描画するのではなく、[`crate::os_lib::cloud`] の
4//! REST + OAuth2 デバイスフローを使い、AtmOS ネイティブ UI で Drive を読み書きする。
5//! 通信は同期的(TLS ハンドシェイク + 受信待ち)に行うため、操作キー押下時に
6//! 数百 ms ブロックする(browser/`net.https` 等の既存挙動と同じ)。
7//! 姉妹アプリ [`crate::apps::onedrive`] と同一設計。
8// drive/mod.rs - Google Drive native client (REST 経由) for AtmOS
9#![allow(dead_code)]
10
11extern crate alloc;
12use alloc::format;
13use alloc::string::{String, ToString};
14use alloc::vec::Vec;
15
16use crate::kernel::draw::{Color, Screen};
17use crate::kernel::text_box::TextBox;
18use crate::kernel::window_mgr::App;
19use crate::os_lib::cloud::{self, DriveFile, GdriveAuth};
20
21/// どのペインにフォーカスしているか。
22#[derive(PartialEq, Clone, Copy)]
23enum Pane {
24    Remote,
25    Local,
26}
27
28/// client_id/secret 設定モードでどちらの入力欄にフォーカスしているか。
29#[derive(PartialEq, Clone, Copy)]
30enum SetupField {
31    ClientId,
32    ClientSecret,
33}
34
35pub struct DriveApp {
36    auth: GdriveAuth,
37    // 一覧
38    remote: Vec<DriveFile>,
39    local: Vec<String>,
40    remote_sel: usize,
41    local_sel: usize,
42    pane: Pane,
43    scroll: usize,
44    // フォルダ階層: ルートからの (id, name) パス。空 = Drive ルート直下を表示中。
45    folder_path: Vec<(String, String)>,
46    // ログインフロー
47    device_user_code: String,
48    device_url: String,
49    device_code: String,
50    awaiting_auth: bool,
51    // ビューア(ファイル本文表示)
52    viewer_open: bool,
53    viewer_title: String,
54    viewer_text: String,
55    viewer_scroll: usize,
56    // 状態行
57    status: String,
58    // 直近の描画サイズ(マウスのペイン判定用)
59    last_win_w: u32,
60    // client_id/secret 設定モード(gdrive_auth.json を手動編集せずアプリ内で設定できる)。
61    setup_mode: bool,
62    setup_focus: SetupField,
63    setup_id_box: TextBox,
64    setup_secret_box: TextBox,
65}
66
67impl Default for DriveApp {
68    fn default() -> Self {
69        Self::new()
70    }
71}
72
73impl DriveApp {
74    pub fn new() -> Self {
75        let auth = cloud::load_auth();
76        let mut app = Self {
77            auth,
78            remote: Vec::new(),
79            local: Vec::new(),
80            remote_sel: 0,
81            local_sel: 0,
82            pane: Pane::Remote,
83            scroll: 0,
84            folder_path: Vec::new(),
85            device_user_code: String::new(),
86            device_url: String::new(),
87            device_code: String::new(),
88            awaiting_auth: false,
89            viewer_open: false,
90            viewer_title: String::new(),
91            viewer_text: String::new(),
92            viewer_scroll: 0,
93            status: String::new(),
94            last_win_w: 900,
95            setup_mode: false,
96            setup_focus: SetupField::ClientId,
97            setup_id_box: TextBox::new(),
98            setup_secret_box: TextBox::new(),
99        };
100        app.reload_local();
101        if app.auth.is_logged_in() {
102            app.status = String::from("Logged in. Press R to load Drive files.");
103        } else if app.auth.has_client() {
104            app.status = String::from("Press L to log in to Google Drive.");
105        } else {
106            app.status = String::from("Setup required: press S to enter client_id/secret.");
107        }
108        app
109    }
110
111    fn reload_local(&mut self) {
112        let fs = crate::kernel::fs::get_fs();
113        self.local = fs
114            .list_files()
115            .iter()
116            .map(|m| m.get_filename().to_string())
117            .collect();
118        if self.local_sel >= self.local.len() {
119            self.local_sel = 0;
120        }
121    }
122
123    /// access_token が空、または期限切れ時に refresh_token で更新を試みる。成功で true。
124    fn try_refresh(&mut self) -> bool {
125        if self.auth.refresh_token.is_empty() || !self.auth.has_client() {
126            return false;
127        }
128        match cloud::drive_refresh_token(
129            &self.auth.client_id,
130            &self.auth.client_secret,
131            &self.auth.refresh_token,
132        ) {
133            Ok(tok) if !tok.access_token.is_empty() => {
134                self.auth.access_token = tok.access_token;
135                // 更新したトークンを保存できなければ、次回起動時にまた
136                // ログインを求められる。動作自体は継続できるので、
137                // 理由を必ず表示・記録したうえで先へ進む。
138                if let Err(e) = cloud::save_auth(&self.auth) {
139                    crate::error!("[DRIVE] refreshed token not saved: {}", e);
140                    self.status = alloc::format!("Warning: token not saved ({})", e);
141                }
142                true
143            }
144            _ => false,
145        }
146    }
147
148    // ---- アクション ----
149
150    fn action_login(&mut self) {
151        if !self.auth.has_client() {
152            self.status = String::from("No client_id/secret. Edit gdrive_auth.json first.");
153            return;
154        }
155        self.status = String::from("Requesting device code...");
156        match cloud::drive_request_device_code(&self.auth.client_id) {
157            Ok(dc) if !dc.user_code.is_empty() => {
158                self.device_user_code = dc.user_code;
159                self.device_url = dc.verification_url;
160                self.device_code = dc.device_code;
161                self.awaiting_auth = true;
162                self.status =
163                    String::from("Open the URL on another device, enter the code, then press P.");
164            }
165            Ok(_) => {
166                self.status =
167                    String::from("Device code request returned empty (check client_id/scope).")
168            }
169            Err(e) => self.status = format!("Device code error: {}", e),
170        }
171    }
172
173    fn action_poll(&mut self) {
174        if !self.awaiting_auth {
175            return;
176        }
177        self.status = String::from("Polling for authorization...");
178        match cloud::drive_poll_token(
179            &self.auth.client_id,
180            &self.auth.client_secret,
181            &self.device_code,
182        ) {
183            Ok(tok) if !tok.access_token.is_empty() => {
184                self.auth.access_token = tok.access_token;
185                if !tok.refresh_token.is_empty() {
186                    self.auth.refresh_token = tok.refresh_token;
187                }
188                self.awaiting_auth = false;
189                // 保存できていないなら「ログインした」だけを伝えるのは不正確
190                // (再起動でログイン状態が消える)。結果に応じて表示を変える。
191                match cloud::save_auth(&self.auth) {
192                    Ok(()) => self.status = String::from("Logged in! Loading Drive files..."),
193                    Err(e) => {
194                        crate::error!("[DRIVE] login token not saved: {}", e);
195                        self.status =
196                            alloc::format!("Logged in, but token NOT saved ({}). Login will not persist.", e);
197                    }
198                }
199                self.action_refresh_remote();
200            }
201            Ok(tok) if tok.error == "authorization_pending" => {
202                self.status =
203                    String::from("Still pending. Authorize in browser, then press P again.");
204            }
205            Ok(tok) if tok.error == "slow_down" => {
206                self.status = String::from("Slow down: wait a few seconds, then press P.");
207            }
208            Ok(tok) if !tok.error.is_empty() => {
209                self.status = format!("Auth error: {}", tok.error);
210            }
211            Ok(_) => self.status = String::from("No token yet. Press P again."),
212            Err(e) => self.status = format!("Poll error: {}", e),
213        }
214    }
215
216    /// 現在のフォルダ(folder_path の末尾。空ならルート)の直下一覧を取得する。
217    fn current_folder_id(&self) -> Option<&str> {
218        self.folder_path.last().map(|(id, _)| id.as_str())
219    }
220
221    fn action_refresh_remote(&mut self) {
222        if !self.auth.is_logged_in() {
223            self.status = String::from("Not logged in.");
224            return;
225        }
226        self.status = String::from("Loading Drive files...");
227        let folder_id = self.current_folder_id().map(String::from);
228        match cloud::drive_list_files_in_folder(&self.auth.access_token, folder_id.as_deref()) {
229            Ok(files) => {
230                self.remote = files;
231                self.remote_sel = 0;
232                self.scroll = 0;
233                self.status = format!("Loaded {} Drive files.", self.remote.len());
234            }
235            Err("unauthorized (token expired?)") => {
236                if self.try_refresh() {
237                    match cloud::drive_list_files_in_folder(
238                        &self.auth.access_token,
239                        folder_id.as_deref(),
240                    ) {
241                        Ok(files) => {
242                            self.remote = files;
243                            self.status = format!("Loaded {} Drive files.", self.remote.len());
244                        }
245                        Err(e) => self.status = format!("Drive list error after refresh: {}", e),
246                    }
247                } else {
248                    self.status =
249                        String::from("Token expired and refresh failed. Press L to re-login.");
250                }
251            }
252            Err(e) => self.status = format!("Drive list error: {}", e),
253        }
254    }
255
256    /// フォルダに入る(folder_path に積んで再読み込み)。
257    fn action_enter_folder(&mut self, id: String, name: String) {
258        self.folder_path.push((id, name));
259        self.action_refresh_remote();
260    }
261
262    /// 1階層上に戻る(folder_path から1つ pop して再読み込み)。ルートなら何もしない。
263    fn action_leave_folder(&mut self) {
264        if self.folder_path.pop().is_some() {
265            self.action_refresh_remote();
266        }
267    }
268
269    fn action_open_remote(&mut self) {
270        if self.remote.is_empty() {
271            return;
272        }
273        let f = self.remote[self.remote_sel].clone();
274        if f.mime_type == cloud::MIME_FOLDER {
275            self.action_enter_folder(f.id, f.name);
276            return;
277        }
278        self.status = format!("Fetching '{}'...", f.name);
279        let mut result = cloud::drive_get_text(&self.auth.access_token, &f.id, &f.mime_type);
280        if let Err("unauthorized (token expired?)") = result {
281            if self.try_refresh() {
282                result = cloud::drive_get_text(&self.auth.access_token, &f.id, &f.mime_type);
283            }
284        }
285        match result {
286            Ok(text) => {
287                self.viewer_title = f.name.clone();
288                self.viewer_text = text;
289                self.viewer_scroll = 0;
290                self.viewer_open = true;
291                self.status = format!("Opened '{}'. Press V to close.", f.name);
292            }
293            Err(e) => self.status = format!("Open error: {}", e),
294        }
295    }
296
297    /// 選択中の remote ファイルの生バイトをそのままローカル FS に保存する
298    /// (画像/zip など、テキストビューアでは壊れるファイル向け)。
299    fn action_download_remote(&mut self) {
300        if self.remote.is_empty() {
301            return;
302        }
303        let f = self.remote[self.remote_sel].clone();
304        if f.mime_type == cloud::MIME_FOLDER {
305            self.status = String::from("That is a folder.");
306            return;
307        }
308        self.status = format!("Downloading '{}'...", f.name);
309        let mut result = cloud::drive_download_binary(&self.auth.access_token, &f.id);
310        if let Err("unauthorized (token expired?)") = result {
311            if self.try_refresh() {
312                result = cloud::drive_download_binary(&self.auth.access_token, &f.id);
313            }
314        }
315        match result {
316            Ok(bytes) => {
317                let len = bytes.len();
318                let fs = crate::kernel::fs::get_fs();
319                match fs.save_file(&f.name, &bytes, "downloaded") {
320                    Ok(()) => {
321                        self.status = format!("Downloaded '{}' ({} bytes).", f.name, len);
322                        self.reload_local();
323                    }
324                    Err(e) => self.status = format!("Download save error: {}", e),
325                }
326            }
327            Err(e) => self.status = format!("Download error: {}", e),
328        }
329    }
330
331    fn action_upload_local(&mut self) {
332        if !self.auth.is_logged_in() {
333            self.status = String::from("Not logged in.");
334            return;
335        }
336        if self.local.is_empty() {
337            return;
338        }
339        let name = self.local[self.local_sel].clone();
340        let fs = crate::kernel::fs::get_fs();
341        let content = match fs.read_file(&name) {
342            Some((_m, bytes)) => String::from_utf8_lossy(&bytes).into_owned(),
343            None => {
344                self.status = format!("Local file '{}' not found.", name);
345                return;
346            }
347        };
348        self.status = format!("Uploading '{}'...", name);
349        let mut result = cloud::drive_create_text(&self.auth.access_token, &name, &content);
350        if let Err("unauthorized (token expired?)") = result {
351            if self.try_refresh() {
352                result = cloud::drive_create_text(&self.auth.access_token, &name, &content);
353            }
354        }
355        match result {
356            Ok(id) => {
357                self.status = format!("Uploaded '{}' (id={}). Press R to refresh.", name, id);
358            }
359            Err(e) => self.status = format!("Upload error: {}", e),
360        }
361    }
362
363    fn action_delete_remote(&mut self) {
364        if self.remote.is_empty() {
365            return;
366        }
367        let f = self.remote[self.remote_sel].clone();
368        self.status = format!("Deleting '{}'...", f.name);
369        let mut result = cloud::drive_delete(&self.auth.access_token, &f.id);
370        if let Err("unauthorized (token expired?)") = result {
371            if self.try_refresh() {
372                result = cloud::drive_delete(&self.auth.access_token, &f.id);
373            }
374        }
375        match result {
376            Ok(()) => {
377                self.remote.remove(self.remote_sel);
378                if self.remote_sel >= self.remote.len() && self.remote_sel > 0 {
379                    self.remote_sel -= 1;
380                }
381                self.status = format!("Deleted '{}'.", f.name);
382            }
383            Err(e) => self.status = format!("Delete error: {}", e),
384        }
385    }
386
387    fn action_logout(&mut self) {
388        self.auth.access_token = String::new();
389        self.auth.refresh_token = String::new();
390        self.remote.clear();
391        self.folder_path.clear();
392        // 【重要】保存に失敗した場合、ディスク上にはトークンが**残ったまま**
393        // なので「ログアウトしました」と表示してはいけない。
394        match cloud::save_auth(&self.auth) {
395            Ok(()) => {
396                self.status = String::from("Logged out (client_id kept). Press L to log in.")
397            }
398            Err(e) => {
399                crate::error!("[DRIVE] logout not persisted: {}", e);
400                self.status = alloc::format!(
401                    "Logout NOT saved ({}). Token may remain on disk!",
402                    e
403                );
404            }
405        }
406    }
407
408    /// client_id/secret 設定モードに入る。現在の値を入力欄の初期値にする
409    /// (`gdrive_auth.json` を手動編集しなくても、アプリ内でこの入力から書き込める)。
410    fn action_enter_setup(&mut self) {
411        self.setup_id_box = TextBox::with_text(&self.auth.client_id);
412        self.setup_secret_box = TextBox::with_text(&self.auth.client_secret);
413        self.setup_focus = SetupField::ClientId;
414        self.setup_mode = true;
415    }
416
417    /// 入力欄の内容を client_id/secret として保存する。
418    fn action_save_setup(&mut self) {
419        self.auth.client_id = self.setup_id_box.text.clone();
420        self.auth.client_secret = self.setup_secret_box.text.clone();
421        match cloud::save_auth(&self.auth) {
422            Ok(()) => {
423                self.setup_mode = false;
424                self.status = if self.auth.has_client() {
425                    String::from("Saved client_id/secret. Press L to log in.")
426                } else {
427                    String::from("Saved, but client_id/secret still empty.")
428                };
429            }
430            Err(e) => self.status = format!("Save error: {}", e),
431        }
432    }
433}
434
435const HEADER_H: u32 = 28;
436const ROW_H: u32 = 20;
437
438impl App for DriveApp {
439    fn name(&self) -> &str {
440        "drive"
441    }
442
443    fn instance_info(&self) -> String {
444        if self.auth.is_logged_in() {
445            String::from("Google Drive")
446        } else {
447            String::from("Google Drive (logged out)")
448        }
449    }
450
451    fn draw(&mut self, screen: &Screen, win_x: u32, win_y: u32, win_w: u32, win_h: u32) {
452        self.last_win_w = win_w;
453        let config = crate::kernel::config::get_config();
454        // 背景
455        screen.boxfill(
456            win_x,
457            win_y,
458            win_x + win_w,
459            win_y + win_h,
460            Color(config.theme.app_bg),
461        );
462        // ヘッダ
463        screen.boxfill(
464            win_x,
465            win_y,
466            win_x + win_w,
467            win_y + HEADER_H,
468            Color(config.theme.window_title_bar),
469        );
470        screen.draw_string_vector(win_x + 12, win_y + 7, "Google Drive", Color(config.theme.window_title_fg), 16);
471        let legend =
472            "L:login P:poll R:refresh Enter:open/enter U:upload B:download D:delete BS:up-folder Tab:pane V:close O:logout S:setup";
473        screen.draw_string_vector(win_x + 170, win_y + 9, legend, Color(config.theme.non_active_window_border), 11);
474
475        let body_y = win_y + HEADER_H + 4;
476
477        // client_id/secret 設定モード。gdrive_auth.json を手動編集しなくてもここで書き込める。
478        if self.setup_mode {
479            screen.draw_string_vector(
480                win_x + 14,
481                body_y + 4,
482                "Google Cloud Console: OAuth client type 'TVs and Limited Input devices',",
483                Color(config.theme.non_active_window_border),
484                12,
485            );
486            screen.draw_string_vector(
487                win_x + 14,
488                body_y + 22,
489                "with Drive API + Docs API enabled and scope https://www.googleapis.com/auth/drive.",
490                Color(config.theme.non_active_window_border),
491                12,
492            );
493            screen.draw_string_vector(
494                win_x + 14,
495                body_y + 52,
496                "Client ID:",
497                Color(config.theme.terminal_fg),
498                13,
499            );
500            self.draw_text_field(
501                screen,
502                win_x + 14,
503                body_y + 70,
504                win_w - 28,
505                &self.setup_id_box,
506                self.setup_focus == SetupField::ClientId,
507                "(paste client_id)",
508                false,
509            );
510            screen.draw_string_vector(
511                win_x + 14,
512                body_y + 108,
513                "Client Secret:",
514                Color(config.theme.terminal_fg),
515                13,
516            );
517            self.draw_text_field(
518                screen,
519                win_x + 14,
520                body_y + 126,
521                win_w - 28,
522                &self.setup_secret_box,
523                self.setup_focus == SetupField::ClientSecret,
524                "(paste client_secret)",
525                true,
526            );
527            screen.draw_string_vector(
528                win_x + 14,
529                body_y + 164,
530                "Tab: switch field   Enter: save   Esc: cancel",
531                Color(config.theme.non_active_window_border),
532                12,
533            );
534            self.draw_status(screen, win_x, win_y, win_w, win_h);
535            return;
536        }
537
538        // セットアップ未完了 or ログインフロー表示
539        if !self.auth.has_client() {
540            let lines = [
541                "Setup required.",
542                "Press S to enter your OAuth client_id/secret directly in this app,",
543                "or save them into 'gdrive_auth.json' yourself:",
544                "1. Google Cloud Console: create an OAuth client of type",
545                "   'TVs and Limited Input devices'. Enable Drive API + Docs API.",
546                "2. Add scope: https://www.googleapis.com/auth/drive",
547                "3. Press S here, paste client_id/secret, press Enter to save.",
548            ];
549            let mut y = body_y + 6;
550            for l in lines.iter() {
551                screen.draw_string_vector(win_x + 14, y, l, Color(config.theme.terminal_fg), 12);
552                y += 20;
553            }
554            self.draw_status(screen, win_x, win_y, win_w, win_h);
555            return;
556        }
557
558        if self.awaiting_auth {
559            screen.draw_string_vector(
560                win_x + 14,
561                body_y + 10,
562                "Authorize this device:",
563                Color(config.theme.terminal_fg),
564                14,
565            );
566            screen.draw_string_vector(
567                win_x + 14,
568                body_y + 40,
569                "1. Open this URL on your phone/PC:",
570                Color(config.theme.non_active_window_border),
571                12,
572            );
573            screen.draw_string_vector(
574                win_x + 30,
575                body_y + 62,
576                &self.device_url,
577                Color(config.theme.active_window_border),
578                13,
579            );
580            screen.draw_string_vector(
581                win_x + 14,
582                body_y + 92,
583                "2. Enter this code:",
584                Color(config.theme.non_active_window_border),
585                12,
586            );
587            screen.draw_string_vector(
588                win_x + 30,
589                body_y + 116,
590                &self.device_user_code,
591                Color(config.theme.focus_highlight),
592                24,
593            );
594            screen.draw_string_vector(
595                win_x + 14,
596                body_y + 152,
597                "3. Press P here to complete login.",
598                Color(config.theme.non_active_window_border),
599                12,
600            );
601            self.draw_status(screen, win_x, win_y, win_w, win_h);
602            return;
603        }
604
605        // ビューア(本文表示)が開いていれば全面に
606        if self.viewer_open {
607            screen.draw_string_vector(
608                win_x + 14,
609                body_y,
610                &format!("View: {}", self.viewer_title),
611                Color(config.theme.focus_highlight),
612                14,
613            );
614            let mut y = body_y + 24;
615            let max_y = win_y + win_h - 28;
616            for line in self.viewer_text.lines().skip(self.viewer_scroll) {
617                if y + ROW_H > max_y {
618                    break;
619                }
620                // 長い行は切り詰め
621                let shown: String = line.chars().take(90).collect();
622                screen.draw_string_vector(win_x + 14, y, &shown, Color(config.theme.terminal_fg), 12);
623                y += 16;
624            }
625            self.draw_status(screen, win_x, win_y, win_w, win_h);
626            return;
627        }
628
629        // パンくず(Drive ルートからの現在フォルダのパス)。ルート表示中は "/" のみ。
630        let mut crumb = String::from("/");
631        for (_, name) in self.folder_path.iter() {
632            crumb.push_str(name);
633            crumb.push('/');
634        }
635        let crumb_shown: String = crumb.chars().take(80).collect();
636        screen.draw_string_vector(
637            win_x + 14,
638            body_y,
639            &crumb_shown,
640            Color(config.theme.non_active_window_border),
641            11,
642        );
643
644        // 2ペイン: 左 Drive / 右 Local
645        let mid_x = win_x + win_w / 2;
646        let remote_focus = self.pane == Pane::Remote;
647        let pane_header_y = body_y + 16;
648        screen.draw_string_vector(
649            win_x + 14,
650            pane_header_y,
651            "Drive (remote)",
652            if remote_focus {
653                Color(config.theme.active_window_border)
654            } else {
655                Color(config.theme.non_active_window_border)
656            },
657            13,
658        );
659        screen.draw_string_vector(
660            mid_x + 14,
661            pane_header_y,
662            "Local (this OS)",
663            if !remote_focus {
664                Color(config.theme.active_window_border)
665            } else {
666                Color(config.theme.non_active_window_border)
667            },
668            13,
669        );
670
671        let list_y = pane_header_y + 22;
672        let max_rows = ((win_h - HEADER_H - 76) / ROW_H) as usize;
673
674        // 左: remote
675        for (i, f) in self
676            .remote
677            .iter()
678            .enumerate()
679            .skip(self.scroll)
680            .take(max_rows)
681        {
682            let y = list_y + (i - self.scroll) as u32 * ROW_H;
683            if remote_focus && i == self.remote_sel {
684                screen.boxfill(
685                    win_x + 8,
686                    y - 2,
687                    mid_x - 8,
688                    y + ROW_H - 4,
689                    Color(config.theme.button_active_bg),
690                );
691            }
692            let is_folder = f.mime_type == cloud::MIME_FOLDER;
693            let is_doc = f.mime_type == cloud::MIME_GOOGLE_DOC;
694            let tag = if is_folder {
695                "[D] "
696            } else if is_doc {
697                "[G] "
698            } else {
699                "    "
700            };
701            let label: String = format!("{}{}", tag, f.name);
702            let shown: String = label.chars().take(40).collect();
703            screen.draw_string_vector(win_x + 14, y, &shown, Color(config.theme.terminal_fg), 12);
704        }
705        if self.remote.is_empty() {
706            screen.draw_string_vector(
707                win_x + 14,
708                list_y,
709                "(press R to load)",
710                Color(config.theme.non_active_window_border),
711                12,
712            );
713        }
714
715        // 右: local
716        for (i, name) in self.local.iter().enumerate().take(max_rows) {
717            let y = list_y + i as u32 * ROW_H;
718            if !remote_focus && i == self.local_sel {
719                screen.boxfill(
720                    mid_x + 8,
721                    y - 2,
722                    win_x + win_w - 8,
723                    y + ROW_H - 4,
724                    Color(config.theme.button_active_bg),
725                );
726            }
727            let shown: String = name.chars().take(40).collect();
728            screen.draw_string_vector(mid_x + 14, y, &shown, Color(config.theme.terminal_fg), 12);
729        }
730
731        self.draw_status(screen, win_x, win_y, win_w, win_h);
732    }
733
734    fn on_key(&mut self, keycode: u8, pressed: bool, ascii: Option<char>) {
735        if !pressed {
736            return;
737        }
738        // client_id/secret 設定モード: Tab で入力欄切替、Enter で保存、Esc でキャンセル、
739        // それ以外は文字キーとしてフォーカス中の入力欄へ転送する。
740        if self.setup_mode {
741            match keycode {
742                0x0F => {
743                    self.setup_focus = if self.setup_focus == SetupField::ClientId {
744                        SetupField::ClientSecret
745                    } else {
746                        SetupField::ClientId
747                    };
748                }
749                0x1C => self.action_save_setup(),
750                0x01 => {
751                    self.setup_mode = false;
752                    self.status = String::from("Setup cancelled.");
753                }
754                _ => {
755                    let tb = match self.setup_focus {
756                        SetupField::ClientId => &mut self.setup_id_box,
757                        SetupField::ClientSecret => &mut self.setup_secret_box,
758                    };
759                    let _ = tb.handle_key(keycode, ascii);
760                }
761            }
762            return;
763        }
764        let c = match ascii {
765            Some(c) => c.to_ascii_lowercase(),
766            None => return,
767        };
768        // ビューア表示中はスクロール/閉じるのみ
769        if self.viewer_open {
770            match c {
771                'v' | 'q' => self.viewer_open = false,
772                'j' => self.viewer_scroll = self.viewer_scroll.saturating_add(1),
773                'k' => self.viewer_scroll = self.viewer_scroll.saturating_sub(1),
774                _ => {}
775            }
776            return;
777        }
778        match c {
779            'l' => self.action_login(),
780            'p' => self.action_poll(),
781            'r' => self.action_refresh_remote(),
782            'u' => self.action_upload_local(),
783            'd' => self.action_delete_remote(),
784            'o' => self.action_logout(),
785            's' => self.action_enter_setup(),
786            'b' if self.pane == Pane::Remote => self.action_download_remote(),
787            '\x08' if self.pane == Pane::Remote => self.action_leave_folder(),
788            '\t' => {
789                self.pane = if self.pane == Pane::Remote {
790                    Pane::Local
791                } else {
792                    Pane::Remote
793                }
794            }
795            '\n' | '\r' => {
796                if self.pane == Pane::Remote {
797                    self.action_open_remote();
798                } else {
799                    self.action_upload_local();
800                }
801            }
802            'j' => self.move_sel(1),
803            'k' => self.move_sel(-1),
804            _ => {}
805        }
806    }
807
808    fn on_mouse(
809        &mut self,
810        local_x: i32,
811        local_y: i32,
812        btn_left: bool,
813        _btn_right: bool,
814        wheel: i32,
815    ) {
816        if wheel != 0 && self.viewer_open {
817            if wheel < 0 {
818                self.viewer_scroll = self.viewer_scroll.saturating_add(1);
819            } else {
820                self.viewer_scroll = self.viewer_scroll.saturating_sub(1);
821            }
822            return;
823        }
824        if !btn_left {
825            return;
826        }
827        // 行選択: ヘッダ下のリスト領域でクリックされたペイン/行を選ぶ
828        let list_top = (HEADER_H + 26) as i32;
829        if local_y < list_top {
830            return;
831        }
832        let row = ((local_y - list_top) / ROW_H as i32).max(0) as usize;
833        // クリック x がウィンドウ中央より左なら remote ペイン、右なら local ペイン。
834        // フォーカスもそのペインへ移す。
835        let mid = (self.last_win_w / 2) as i32;
836        if local_x < mid {
837            self.pane = Pane::Remote;
838            if row + self.scroll < self.remote.len() {
839                self.remote_sel = row + self.scroll;
840            }
841        } else {
842            self.pane = Pane::Local;
843            if row < self.local.len() {
844                self.local_sel = row;
845            }
846        }
847    }
848}
849
850impl DriveApp {
851    fn move_sel(&mut self, delta: i32) {
852        if self.pane == Pane::Remote {
853            let n = self.remote.len();
854            if n == 0 {
855                return;
856            }
857            let cur = self.remote_sel as i32 + delta;
858            self.remote_sel = cur.clamp(0, n as i32 - 1) as usize;
859        } else {
860            let n = self.local.len();
861            if n == 0 {
862                return;
863            }
864            let cur = self.local_sel as i32 + delta;
865            self.local_sel = cur.clamp(0, n as i32 - 1) as usize;
866        }
867    }
868
869    #[allow(clippy::too_many_arguments)]
870    fn draw_text_field(
871        &self,
872        screen: &Screen,
873        x: u32,
874        y: u32,
875        w: u32,
876        tb: &TextBox,
877        focused: bool,
878        placeholder: &str,
879        mask: bool,
880    ) {
881        let t = &crate::kernel::config::get_config().theme;
882        let border = if focused { t.active_window_border } else { t.browser_secondary };
883        let bg = if focused { t.browser_bg } else { t.ui_input_bg };
884        screen.boxfill(x, y, x + w, y + 28, Color(bg));
885        screen.boxfill(x, y, x + w, y + 1, Color(border));
886        screen.boxfill(x, y + 27, x + w, y + 28, Color(border));
887        screen.boxfill(x, y, x + 1, y + 28, Color(border));
888        screen.boxfill(x + w - 1, y, x + w, y + 28, Color(border));
889
890        let display: String = if tb.text.is_empty() && !focused {
891            String::from(placeholder)
892        } else if focused {
893            tb.get_text_with_cursor(true)
894                .chars()
895                .map(|c| if mask && c != '|' { '*' } else { c })
896                .collect()
897        } else if mask {
898            tb.text.chars().map(|_| '*').collect()
899        } else {
900            tb.text.clone()
901        };
902
903        let text_color = if tb.text.is_empty() && !focused {
904            t.fn_bar_dim_fg
905        } else {
906            t.login_text_fg
907        };
908        let shown: String = display.chars().take(60).collect();
909        screen.draw_string_vector(x + 8, y + 7, &shown, Color(text_color), 13);
910    }
911
912    fn draw_status(&self, screen: &Screen, win_x: u32, win_y: u32, win_w: u32, win_h: u32) {
913        let config = crate::kernel::config::get_config();
914        let bar_y = win_y + win_h - 24;
915        screen.boxfill(
916            win_x,
917            bar_y,
918            win_x + win_w,
919            win_y + win_h,
920            Color(config.theme.window_title_bar),
921        );
922        let shown: String = self.status.chars().take(110).collect();
923        screen.draw_string_vector(win_x + 12, bar_y + 5, &shown, Color(config.theme.window_title_fg), 12);
924    }
925}