mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat(email): faster inbox load, Ctrl+Tab tab cycling, square preset buttons
- Inbox load: add an mtime-keyed in-memory cache to listInboxPage so it no longer re-reads and JSON.parses every cached thread (bodies included) on every call — only files whose mtime changed are re-parsed. Speeds up the Important→Everything-else handoff, live reloads during sync, and re-opening the inbox tab. - Add Ctrl+Tab / Ctrl+Shift+Tab to cycle to the next/previous tab (wraps around), alongside the existing Cmd+Shift+] / [ shortcuts. - Improve bar: make the tone preset buttons square (rounded-md), left-aligned, with a bit of vertical gap so wrapped rows aren't cramped.
This commit is contained in:
parent
824610f84f
commit
4a26bc9d80
3 changed files with 85 additions and 16 deletions
|
|
@ -4727,6 +4727,25 @@ function App() {
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+Tab — next tab, Ctrl+Shift+Tab — previous tab (browser-style).
|
||||||
|
// Bound to Ctrl specifically (Cmd+Tab is the OS app switcher on macOS).
|
||||||
|
if (e.ctrlKey && e.key === 'Tab') {
|
||||||
|
e.preventDefault()
|
||||||
|
const direction = e.shiftKey ? -1 : 1
|
||||||
|
if (inFileView) {
|
||||||
|
const currentIdx = fileTabs.findIndex(t => t.id === targetFileTabId)
|
||||||
|
if (currentIdx === -1) return
|
||||||
|
const nextIdx = (currentIdx + direction + fileTabs.length) % fileTabs.length
|
||||||
|
switchFileTab(fileTabs[nextIdx].id)
|
||||||
|
} else {
|
||||||
|
const currentIdx = chatTabs.findIndex(t => t.id === activeChatTabId)
|
||||||
|
if (currentIdx === -1) return
|
||||||
|
const nextIdx = (currentIdx + direction + chatTabs.length) % chatTabs.length
|
||||||
|
switchChatTab(chatTabs[nextIdx].id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', handleTabKeyDown)
|
document.addEventListener('keydown', handleTabKeyDown)
|
||||||
return () => document.removeEventListener('keydown', handleTabKeyDown)
|
return () => document.removeEventListener('keydown', handleTabKeyDown)
|
||||||
|
|
|
||||||
|
|
@ -1398,12 +1398,12 @@ const ComposeBox = memo(function ComposeBox({
|
||||||
: (hasGenerated ? 'Edit' : 'Write')}
|
: (hasGenerated ? 'Edit' : 'Write')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1.5 border-b border-border px-3 pb-2.5">
|
<div className="flex flex-wrap gap-x-1.5 gap-y-2 border-b border-border px-3 pb-2.5">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
className="rounded-full"
|
className="rounded-md"
|
||||||
onClick={() => { void runAi('Improve the clarity, grammar, and flow of this email while preserving its meaning.', 'rewrite') }}
|
onClick={() => { void runAi('Improve the clarity, grammar, and flow of this email while preserving its meaning.', 'rewrite') }}
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
>Improve</Button>
|
>Improve</Button>
|
||||||
|
|
@ -1413,7 +1413,7 @@ const ComposeBox = memo(function ComposeBox({
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
className="rounded-full"
|
className="rounded-md"
|
||||||
onClick={() => { void runAi(preset.instruction, 'rewrite') }}
|
onClick={() => { void runAi(preset.instruction, 'rewrite') }}
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
>{preset.label}</Button>
|
>{preset.label}</Button>
|
||||||
|
|
|
||||||
|
|
@ -615,11 +615,29 @@ export function listEverythingElseThreads(opts: { cursor?: string; limit?: numbe
|
||||||
return listInboxPage({ section: 'other', ...opts });
|
return listInboxPage({ section: 'other', ...opts });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-memory index of parsed snapshots, keyed by cache filename and validated by
|
||||||
|
// file mtime. listInboxPage runs on every inbox open, every "load more", and
|
||||||
|
// every throttled live reload during a sync — without this it re-reads and
|
||||||
|
// JSON.parses every cached thread (message bodies included) on each call. The
|
||||||
|
// cache lets unchanged files skip the read+parse, so e.g. the "Everything else"
|
||||||
|
// page right after "Important", reloads mid-sync, and re-opening the inbox cost
|
||||||
|
// a cheap stat() per file instead of a full parse of the whole cache.
|
||||||
|
interface ListCacheEntry {
|
||||||
|
mtimeMs: number;
|
||||||
|
dateMs: number;
|
||||||
|
section: InboxSection;
|
||||||
|
snapshot: GmailThreadSnapshot;
|
||||||
|
}
|
||||||
|
const listCache = new Map<string, ListCacheEntry>();
|
||||||
|
|
||||||
export function listInboxPage(opts: InboxPageOptions): InboxPageResult {
|
export function listInboxPage(opts: InboxPageOptions): InboxPageResult {
|
||||||
const limit = Math.max(1, Math.min(100, opts.limit ?? 25));
|
const limit = Math.max(1, Math.min(100, opts.limit ?? 25));
|
||||||
const cursor = parseCursor(opts.cursor);
|
const cursor = parseCursor(opts.cursor);
|
||||||
|
|
||||||
if (!fs.existsSync(CACHE_DIR)) return { threads: [], nextCursor: null };
|
if (!fs.existsSync(CACHE_DIR)) {
|
||||||
|
listCache.clear();
|
||||||
|
return { threads: [], nextCursor: null };
|
||||||
|
}
|
||||||
|
|
||||||
let names: string[];
|
let names: string[];
|
||||||
try {
|
try {
|
||||||
|
|
@ -628,24 +646,56 @@ export function listInboxPage(opts: InboxPageOptions): InboxPageResult {
|
||||||
return { threads: [], nextCursor: null };
|
return { threads: [], nextCursor: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
const entries: IndexedEntry[] = [];
|
const entries: IndexedEntry[] = [];
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
if (!name.endsWith('.json')) continue;
|
if (!name.endsWith('.json')) continue;
|
||||||
|
seen.add(name);
|
||||||
const filePath = path.join(CACHE_DIR, name);
|
const filePath = path.join(CACHE_DIR, name);
|
||||||
|
|
||||||
|
let mtimeMs: number;
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
mtimeMs = fs.statSync(filePath).mtimeMs;
|
||||||
const wrapper = JSON.parse(raw) as SnapshotCacheEntry;
|
} catch {
|
||||||
const snapshot = wrapper.snapshot;
|
listCache.delete(name);
|
||||||
if (!snapshot) continue;
|
continue;
|
||||||
if (snapshotImportance(snapshot) !== opts.section) continue;
|
|
||||||
entries.push({
|
|
||||||
threadId: snapshot.threadId,
|
|
||||||
dateMs: snapshotDateMs(snapshot),
|
|
||||||
snapshot,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn(`[Inbox lists] read failed for ${name}:`, err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cached = listCache.get(name);
|
||||||
|
if (!cached || cached.mtimeMs !== mtimeMs) {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const wrapper = JSON.parse(raw) as SnapshotCacheEntry;
|
||||||
|
const snapshot = wrapper.snapshot;
|
||||||
|
if (!snapshot) {
|
||||||
|
listCache.delete(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cached = {
|
||||||
|
mtimeMs,
|
||||||
|
dateMs: snapshotDateMs(snapshot),
|
||||||
|
section: snapshotImportance(snapshot),
|
||||||
|
snapshot,
|
||||||
|
};
|
||||||
|
listCache.set(name, cached);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[Inbox lists] read failed for ${name}:`, err);
|
||||||
|
listCache.delete(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached.section !== opts.section) continue;
|
||||||
|
entries.push({
|
||||||
|
threadId: cached.snapshot.threadId,
|
||||||
|
dateMs: cached.dateMs,
|
||||||
|
snapshot: cached.snapshot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict cache entries for files that are gone (archived/trashed/pruned).
|
||||||
|
for (const key of listCache.keys()) {
|
||||||
|
if (!seen.has(key)) listCache.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Newest first, threadId asc as tiebreak.
|
// Newest first, threadId asc as tiebreak.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue