#include "storage_app.h" #include #include #include #include "cleanup_plan.h" #include "file_names.h" #include "ui/canvas.h" #include "ui/fonts.h" #include "ui/widgets.h" namespace roro { using files::baseName; using files::joinPath; using files::parentOf; namespace { constexpr uint32_t kMessageMs = 4000; constexpr uint32_t kQuietMs = 300; // an operation shorter than this shows no progress const char* kindText(const std::string& name) { switch (files::kindOf(name)) { case files::FileKind::Text: return "Text"; case files::FileKind::Gpx: return "Track (GPX)"; case files::FileKind::Pcap: return "Capture (pcap)"; case files::FileKind::Ota: return "Update File"; default: return "File"; } } std::string plural(uint32_t n, const char* one) { return std::to_string(n) + " " + one + (n == 1 ? "" : "s"); } } // namespace void StorageApp::onEnter() { view_ = ESP.getFreeHeap() < kStartFloor ? View::NoMemory : View::Browse; ask_ = Ask::None; dialog_.reset(); message_.clear(); cardPresent_ = storage_.state().present; // A copy left running is picked up where it is; anything else starts from a fresh listing. if (wait_ != Wait::Work && view_ == View::Browse) open(cwd_); else want_ = cwd_; requestRedraw(); } void StorageApp::onExit() { if (wait_ != Wait::None && wait_ != Wait::Work) { ops_.cancel(); wait_ = Wait::None; } noteEditor_.close(); // saves viewer_.close(); wantList_ = false; loaded_ = false; list_ = files::FileList(); // its memory only while the App is open rows_.setCount(0); std::vector().swap(details_); } void StorageApp::say(const std::string& text) { message_ = text; messageMs_ = millis(); requestRedraw(); } // What's on screen stays, with its path, until the new listing is in: cwd_ changes then. void StorageApp::open(const std::string& folder, const std::string& select) { want_ = folder; selectAfter_ = select; wantList_ = true; // started from update(), once the card is free waitSinceMs_ = millis(); } StorageApp::Item StorageApp::selected() const { int i = rows_.selected(); if (!loaded_ || i < 0 || i >= static_cast(list_.count())) return {}; Item item; item.path = joinPath(cwd_, list_.name(i)); item.folder = list_.folder(i); return item; } bool StorageApp::work(const std::string& why, const std::string& selectAfter) { if (!why.empty()) { say(why); return false; } wait_ = Wait::Work; waitSinceMs_ = millis(); selectAfter_ = selectAfter; return true; } void StorageApp::update(uint32_t nowMs) { bool present = storage_.state().present; if (present != cardPresent_) { // taken out, put in, or erased: back to the top cardPresent_ = present; cwd_ = want_ = "/"; selectAfter_.clear(); loaded_ = false; list_.clear(); rows_.setCount(0); wantList_ = present; clip_ = Item(); if (view_ == View::Viewer) viewer_.close(); if (view_ == View::Editor) noteEditor_.close(); if (view_ == View::Details || view_ == View::Name || view_ == View::Viewer || view_ == View::Editor) view_ = View::Browse; requestRedraw(); } if (wantList_ && present && wait_ == Wait::None && !ops_.busy() && ops_.list(want_).empty()) { wantList_ = false; wait_ = Wait::List; waitSinceMs_ = nowMs; } if (view_ == View::Viewer && viewer_.update(nowMs)) requestRedraw(); if (view_ == View::Editor && noteEditor_.update(nowMs)) requestRedraw(); FileOps::Status s; if (wait_ != Wait::None && ops_.finished(s)) { onFinished(s); requestRedraw(); } // millis(), not nowMs: a message set by a key in this same pass is newer than the pass's own time. uint32_t now = millis(); if ((wait_ != Wait::None || wantList_) && now - lastDrawMs_ >= 250) requestRedraw(); if (!message_.empty() && now - messageMs_ >= kMessageMs) { message_.clear(); requestRedraw(); } if (view_ == View::Maintenance && now - lastDrawMs_ >= 1000) requestRedraw(); } void StorageApp::onFinished(const FileOps::Status& s) { Wait was = wait_; wait_ = Wait::None; switch (was) { case Wait::List: { if (!s.error.empty()) { say(s.error); selectAfter_.clear(); // A folder that can't be opened leaves the screen as it is; if it's the one on // screen that went away, back to the top. if (want_ != cwd_ && loaded_) { want_ = cwd_; return; } if (want_ != "/") return open("/"); list_.clear(); } else { ops_.takeListing(list_); list_.sort(sort_); } cwd_ = want_; loaded_ = true; int count = static_cast(list_.count()) + (cwd_ == "/" ? 1 : 0); int at = selectAfter_.empty() ? -1 : list_.find(selectAfter_); rows_.setCount(count); rows_.select(at >= 0 ? at : selectIndex_); selectAfter_.clear(); selectIndex_ = 0; break; } case Wait::CountToDelete: if (!s.error.empty()) say(s.error); else if (!s.cancelled) askDelete(target_, s.files, s.bytes); break; case Wait::CountForDetails: if (view_ != View::Details || details_.empty() || s.cancelled) break; details_.back() = s.error.empty() ? plural(s.files, "file") + ", " + formatBytes(s.bytes) : s.error; break; case Wait::Work: { std::string name = files::fitName(baseName(target_.path), 20); if (s.exists && pasting_) { question_ = name + " exists already in this folder. Replace it?"; ask_ = Ask::Replace; dialog_.reset(new DialogModel({"Cancel", "Replace"})); return; } if (!s.error.empty()) say(s.error); else if (s.cancelled) say(s.op == FileOps::Op::Copy ? "Cancelled: nothing was copied" : "Cancelled after " + plural(s.files, "file")); else { switch (s.op) { case FileOps::Op::Copy: say(s.files == 1 ? "Copied " + name : "Copied " + plural(s.files, "file") + " to " + name); break; case FileOps::Op::Move: say((pasting_ ? "Moved " : "Renamed to ") + name); break; case FileOps::Op::Delete: say(s.files == 1 ? "Deleted " + name : "Deleted " + name + ", " + plural(s.files, "file")); break; default: say("Made " + name); break; } // The clipboard must not point at what has moved or gone. bool gone = s.op == FileOps::Op::Delete || s.op == FileOps::Op::Move; if (clip_ && gone && files::isInside(clip_.path, s.path)) clip_ = Item(); } open(cwd_, selectAfter_); break; } default: break; } } void StorageApp::paste(bool replace) { if (!clip_) return say("Nothing to paste: c copies, x cuts"); std::string name = baseName(clip_.path), why; if (cut_) { why = ops_.move(clip_.path, clip_.folder, joinPath(cwd_, name), replace); } else { // A copy next to its original: "a (2).txt", or the next number that's free. if (parentOf(clip_.path) == cwd_) for (int n = 2; n < 100 && list_.find(name) >= 0; n++) name = files::copyName(baseName(clip_.path), n); why = ops_.copy(clip_.path, clip_.folder, cwd_, name, replace); } target_.path = joinPath(cwd_, name); target_.folder = clip_.folder; pasting_ = true; selectIndex_ = rows_.selected(); // where to stay if nothing comes of it work(why, name); } void StorageApp::askDelete(const Item& item, uint32_t count, uint32_t bytes) { std::string name = files::fitName(baseName(item.path), 22); if (!item.folder) question_ = "Delete " + name + " (" + formatBytes(bytes) + ")?"; else if (count == 0) question_ = "Delete the empty folder " + name + "?"; else question_ = "Delete " + name + " and its " + plural(count, "file") + " (" + formatBytes(bytes) + ")?"; question_ += " It can't be undone."; target_ = item; ask_ = Ask::Delete; dialog_.reset(new DialogModel({"Cancel", "Delete"})); requestRedraw(); } void StorageApp::onAnswer(int button) { Ask asked = ask_; ask_ = Ask::None; dialog_.reset(); if (button != 1) return; switch (asked) { case Ask::Delete: selectIndex_ = rows_.selected(); pasting_ = false; work(ops_.remove(target_.path, target_.folder), ""); break; case Ask::Replace: paste(true); break; case Ask::Maintenance: view_ = View::Maintenance; maintenance_.enter(); break; default: break; } } void StorageApp::showDetails(const Item& item) { int i = rows_.selected(); std::string name = baseName(item.path); details_.clear(); details_.push_back(name); if (item.folder) details_.push_back("Folder"); else { uint32_t size = list_.size(i); std::string text = std::string(kindText(name)) + ", " + formatBytes(size); if (size >= 1024) text += " (" + std::to_string(size) + " bytes)"; details_.push_back(text); } details_.push_back("Modified " + files::formatStamp(list_.modified(i))); details_.push_back("In " + cwd_); std::string why = ops_.whyReadOnly(item.path, item.folder); if (!why.empty()) details_.push_back("Read-only: " + why); if (item.folder) { details_.push_back("Counting..."); if (ops_.count(item.path).empty()) wait_ = Wait::CountForDetails; else details_.pop_back(); } detailsTop_ = 0; view_ = View::Details; } bool StorageApp::onKey(const KeyEvent& e) { requestRedraw(); if (view_ == View::NoMemory) return false; if (view_ == View::Maintenance) { if (!maintenance_.onKey(e)) { view_ = View::Browse; open(cwd_); // a Clean-up may have emptied what was shown } return true; } if (view_ == View::Editor) { if (!noteEditor_.onKey(e)) { std::string name = baseName(noteEditor_.path()); noteEditor_.close(); view_ = View::Browse; open(cwd_, name); // its size and date have changed } return true; } if (view_ == View::Viewer && viewer_.showingText() && e.key == Key::Char && (e.ch == 'e' || e.ch == 'E')) { // Edit it (Q146), if the rules and its size allow. std::string path = viewer_.path(), why = ops_.whyReadOnly(path, false); if (why.empty() && viewer_.size() > notes::NoteText::kMaxBytes) why = "Too big to edit: 16 KB at most"; if (why.empty()) { viewer_.close(); why = noteEditor_.open(path); if (why.empty()) view_ = View::Editor; else { view_ = View::Browse; say(why); } } else { viewer_.say(why); } return true; } if (view_ == View::Viewer) { if (!viewer_.onKey(e)) { viewer_.close(); view_ = View::Browse; } return true; } if (dialog_) { dialog_->onKey(e); if (dialog_->result() != DialogModel::kPending) onAnswer(dialog_->result()); return true; } if (view_ == View::Details) { if (e.key == Key::Up) detailsTop_ = std::max(0, detailsTop_ - 1); else if (e.key == Key::Down) detailsTop_++; // clamped when drawn else if (e.key == Key::Back || e.key == Key::Select) { if (wait_ == Wait::CountForDetails) ops_.cancel(); view_ = View::Browse; } return true; } // A copy, a delete or a count is running: Back stops it, nothing else gets through. The same // while a folder is being read, which nothing stops. if (wantList_) return true; if (wait_ != Wait::None) { if (e.key == Key::Back && wait_ != Wait::List) ops_.cancel(); return true; } if (view_ == View::Name) return onNameKey(e); return onBrowseKey(e); } bool StorageApp::onNameKey(const KeyEvent& e) { switch (e.key) { case Key::Char: editor_.insert(e.ch); break; case Key::Delete: editor_.backspace(); break; case Key::Left: editor_.left(); break; case Key::Right: editor_.right(); break; case Key::Back: view_ = View::Browse; break; case Key::Select: { const std::string& name = editor_.text(); std::string why = files::checkName(name); if (!why.empty()) { say(why); break; } pasting_ = false; bool started = true; if (!renaming_) { target_.path = joinPath(cwd_, name); target_.folder = true; started = work(ops_.makeFolder(target_.path), name); } else if (name != baseName(target_.path)) { std::string to = joinPath(cwd_, name); started = work(ops_.move(target_.path, target_.folder, to), name); if (started) target_.path = to; } if (started) view_ = View::Browse; break; } default: break; } return true; } bool StorageApp::onBrowseKey(const KeyEvent& e) { auto askMaintenance = [this]() { question_ = "Clean-up and Erase delete files for good: there is no undo."; ask_ = Ask::Maintenance; dialog_.reset(new DialogModel({"Back", "Go on"})); }; if (e.key == Key::Back) { if (cwd_ == "/" || !cardPresent_) return false; // leaves the App open(parentOf(cwd_), baseName(cwd_)); return true; } uint32_t ch = e.key == Key::Char ? (e.ch >= 'A' && e.ch <= 'Z' ? e.ch + 32 : e.ch) : 0; if (ch == 'm') askMaintenance(); if (!cardPresent_ || !loaded_) return true; Item item = selected(); switch (e.key) { case Key::Up: rows_.up(); return true; case Key::Down: rows_.down(); return true; case Key::Left: rows_.pageUp(); return true; case Key::Right: rows_.pageDown(); return true; case Key::Select: if (onMaintenanceRow()) askMaintenance(); else if (item && item.folder) open(item.path); else if (item) { viewer_.open(item.path, list_.size(rows_.selected())); view_ = View::Viewer; } return true; case Key::Delete: ch = 'd'; break; default: break; } switch (ch) { case 'v': paste(false); return true; case 'n': renaming_ = false; editor_.setText(""); view_ = View::Name; return true; case 's': { sort_ = sort_ == files::FileSort::Name ? files::FileSort::Date : sort_ == files::FileSort::Date ? files::FileSort::Size : files::FileSort::Name; std::string keep = item ? baseName(item.path) : ""; bool onLast = onMaintenanceRow(); list_.sort(sort_); if (!onLast) rows_.select(std::max(0, list_.find(keep))); say(sort_ == files::FileSort::Name ? "Sorted by name" : sort_ == files::FileSort::Date ? "Sorted by date, newest first" : "Sorted by size, biggest first"); return true; } default: break; } if (!item || !(ch == 'c' || ch == 'x' || ch == 'r' || ch == 'd' || ch == 'i')) return true; std::string name = files::fitName(baseName(item.path), 18); if (ch == 'i') { showDetails(item); return true; } if (ch == 'c') { clip_ = item; cut_ = false; say("v pastes a copy of " + name); return true; } // Cut, rename and delete change the original: the rules of Q130 first. std::string why = ops_.whyReadOnly(item.path, item.folder); if (!why.empty()) { say(why); return true; } if (ch == 'x') { clip_ = item; cut_ = true; say("v moves " + name + " here"); } else if (ch == 'r') { target_ = item; renaming_ = true; editor_.setText(baseName(item.path)); view_ = View::Name; } else if (item.folder) { // 'd': what's inside is counted first (Q132) target_ = item; std::string busy = ops_.count(item.path); if (!busy.empty()) say(busy); else { wait_ = Wait::CountToDelete; waitSinceMs_ = millis(); } } else { askDelete(item, 1, list_.size(rows_.selected())); } return true; } void StorageApp::draw(Canvas& c) { lastDrawMs_ = millis(); const auto& area = theme::kContent; c.setTextDatum(top_left); switch (view_) { case View::NoMemory: c.setFont(&fonts::bold); c.setTextColor(theme::kWarning); c.drawString("Not enough memory", 4, area.y + 4); c.setFont(&fonts::body); c.setTextColor(theme::kMuted); c.drawString("Close what's running (IRC, a page", 4, area.y + 22); c.drawString("in Gemini) and open Storage again.", 4, area.y + 22 + theme::kLineHeight); return; case View::Maintenance: maintenance_.draw(c); return; case View::Viewer: viewer_.draw(c); return; case View::Editor: noteEditor_.draw(c); return; case View::Details: { std::vector lines; auto measure = widgets::bodyMeasure(c); for (auto& d : details_) for (auto& l : wrapText(d, area.w - 12, measure)) lines.push_back(l); int rows = (area.h - 2) / theme::kLineHeight; detailsTop_ = std::clamp(detailsTop_, 0, std::max(0, static_cast(lines.size()) - rows)); widgets::textLines(c, lines, detailsTop_, {area.x + 2, area.y + 2, area.w - 2, area.h - 2}); return; } default: break; } drawBrowse(c); if (view_ == View::Name) { theme::Rect box{16, 44, theme::kWidth - 32, 48}; c.fillRect(box.x, box.y, box.w, box.h, theme::kBackground); c.drawRect(box.x, box.y, box.w, box.h, theme::kAccent); c.setFont(&fonts::bold); c.setTextColor(theme::kText); c.drawString(renaming_ ? "Rename" : "New folder", box.x + 6, box.y + 4); widgets::lineEditor(c, editor_, {box.x + 6, box.y + 22, box.w - 12, theme::kLineHeight + 4}); } if (dialog_) { const char* title = ask_ == Ask::Delete ? "Delete" : ask_ == Ask::Replace ? "Replace" : "Maintenance"; widgets::dialog(c, title, question_, *dialog_); } else if ((wait_ == Wait::Work || wait_ == Wait::CountToDelete) && millis() - waitSinceMs_ >= kQuietMs) { drawProgress(c); } } void StorageApp::drawBrowse(Canvas& c) { const auto& area = theme::kContent; StorageState card = storage_.state(); // Where we are, and how much room is left (Q129). c.setFont(&fonts::small); c.setTextColor(theme::kMuted); std::string right; if (card.present) { right = formatBytes(card.totalBytes - card.usedBytes) + " free"; if (loaded_ && list_.more() > 0) right = "first " + std::to_string(list_.count()) + " of " + std::to_string(list_.count() + list_.more()); } size_t room = static_cast((area.w - 12) / 5) - right.size() - 1; std::string path = cwd_.size() <= room ? cwd_ : ".." + cwd_.substr(cwd_.size() - (room - 2)); c.drawString(path.c_str(), 4, area.y + 1); c.setTextDatum(top_right); c.drawString(right.c_str(), area.w - 3, area.y + 1); c.setTextDatum(top_left); theme::Rect rows{area.x, area.y + 10, area.w, kRows * theme::kLineHeight}; c.setFont(&fonts::body); if (!card.present) { c.setTextColor(theme::kWarning); c.drawString("No SD card", 4, rows.y + 4); } else if (!loaded_) { // Nothing yet: the footer says what's being read. } else if (rows_.count() == 0) { c.setTextColor(theme::kMuted); c.drawString("Nothing in this folder.", 4, rows.y + 4); c.drawString("n makes a folder, v pastes.", 4, rows.y + 4 + theme::kLineHeight); } else { int entries = static_cast(list_.count()); auto value = [&](int i) { return i >= entries ? std::string(">") : files::rowDetail(list_.folder(i), list_.size(i), list_.modified(i)); }; widgets::list( c, rows_, rows, [&](int i) { if (i >= entries) return std::string("Maintenance"); size_t fits = static_cast((area.w - 22) / 6) - value(i).size(); return files::fitName(list_.name(i), fits); }, value); } // Footer: a message for a few seconds, what v would paste, or the keys. c.setFont(&fonts::small); bool showMessage = !message_.empty() && millis() - messageMs_ < kMessageMs; c.setTextColor(showMessage ? theme::kWarning : theme::kMuted); std::string keys = "c x v:paste r:name d:del n:new i:info s:sort"; if ((wantList_ || wait_ == Wait::List) && millis() - waitSinceMs_ >= kQuietMs) keys = "Reading " + files::fitName(want_, 30) + "..."; else if (clip_) keys = std::string("v: ") + (cut_ ? "move " : "copy ") + files::fitName(baseName(clip_.path), 20) + " here"; c.drawString(showMessage ? message_.c_str() : keys.c_str(), 4, area.y + area.h - 9); } void StorageApp::drawProgress(Canvas& c) { FileOps::Status s = ops_.status(); theme::Rect box{20, 40, theme::kWidth - 40, 58}; c.fillRect(box.x, box.y, box.w, box.h, theme::kBackground); c.drawRect(box.x, box.y, box.w, box.h, theme::kAccent); c.setTextDatum(top_left); c.setFont(&fonts::bold); c.setTextColor(theme::kText); const char* verb = s.op == FileOps::Op::Copy ? "Copying " : s.op == FileOps::Op::Delete ? "Deleting " : s.op == FileOps::Op::Count ? "Counting " : "Moving "; c.drawString((verb + files::fitName(baseName(target_.path), 20)).c_str(), box.x + 6, box.y + 4); c.setFont(&fonts::body); c.setTextColor(theme::kMuted); std::string line = plural(s.files, "file"); if (s.op == FileOps::Op::Copy && s.totalBytes > 0) { int w = box.w - 12, done = static_cast(static_cast(s.doneBytes) * w / s.totalBytes); c.drawRect(box.x + 6, box.y + 21, w, 7, theme::kMuted); c.fillRect(box.x + 6, box.y + 21, std::min(done, w), 7, theme::kAccent); line = formatBytes(s.doneBytes) + " of " + formatBytes(s.totalBytes); } c.drawString(line.c_str(), box.x + 6, box.y + 31); c.setFont(&fonts::small); c.drawString("Back: cancel", box.x + 6, box.y + box.h - 10); } } // namespace roro