Skip to main content

atmos/apps/onedrive/
mod.rs

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