@bobfrankston/rmfmail 1.1.36 → 1.1.38
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/app.bundle.js +205 -35
- package/client/app.bundle.js.map +4 -4
- package/client/app.js +42 -0
- package/client/app.js.map +1 -1
- package/client/app.ts +41 -0
- package/client/components/message-list.js +58 -35
- package/client/components/message-list.js.map +1 -1
- package/client/components/message-list.ts +55 -34
- package/client/components/tabs.js +155 -0
- package/client/components/tabs.js.map +1 -0
- package/client/components/tabs.ts +163 -0
- package/client/index.html +4 -0
- package/client/styles/components.css +58 -0
- package/client/styles/layout.css +15 -9
- package/docs/multi-view.md +81 -0
- package/package.json +3 -2
- /package/packages/mailx-imap/{node_modules.npmglobalize-stash-45456 → node_modules.npmglobalize-stash-49720}/.package-lock.json +0 -0
|
@@ -82,7 +82,7 @@ function cacheKey(mode: "folder" | "search", a?: string, f?: number, flagged?: b
|
|
|
82
82
|
* Gmail's hash-derived ids). If no smaller uid exists either, fall
|
|
83
83
|
* back to whatever row sits at the same numeric index. Survives a
|
|
84
84
|
* session reload via sessionStorage. */
|
|
85
|
-
interface ViewPosition { uid: number; scroll: number; }
|
|
85
|
+
interface ViewPosition { uid: number; uuid?: string; scroll: number; }
|
|
86
86
|
const positionMemory = new Map<string, ViewPosition>();
|
|
87
87
|
const POSITION_STORAGE_KEY = "mailx-list-positions";
|
|
88
88
|
try {
|
|
@@ -117,25 +117,36 @@ function rememberPosition(): void {
|
|
|
117
117
|
if (!sel) return;
|
|
118
118
|
const uid = Number(sel.dataset.uid);
|
|
119
119
|
if (!Number.isFinite(uid)) return;
|
|
120
|
-
|
|
120
|
+
// Remember the stable `uuid` as the primary identity — bare `uid` is only
|
|
121
|
+
// unique within (account, folder), so restoring by it can rebind the
|
|
122
|
+
// viewer to a different message that happens to share the uid number.
|
|
123
|
+
// `uid` is kept only for the next-older-neighbor fallback when the
|
|
124
|
+
// remembered message has since been deleted.
|
|
125
|
+
positionMemory.set(key, { uid, uuid: sel.dataset.uuid || "", scroll: body.scrollTop });
|
|
121
126
|
persistPositions();
|
|
122
127
|
}
|
|
123
128
|
/** Choose the row to focus when re-entering a view with saved position.
|
|
124
129
|
* Returns the uid to focus, or null to fall back to selectFirst. */
|
|
125
|
-
function pickRestoreUid(items: any[], saved:
|
|
130
|
+
function pickRestoreUid(items: any[], saved: ViewPosition): string | null {
|
|
126
131
|
if (!items.length) return null;
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
132
|
+
// Exact restore by stable uuid — globally unique, so this can never
|
|
133
|
+
// rebind the viewer to a same-uid-number message in another folder
|
|
134
|
+
// (the bug: viewer silently jumped to a different letter mid-cleanup).
|
|
135
|
+
if (saved.uuid) {
|
|
136
|
+
const exact = items.find(m => m.uuid && m.uuid === saved.uuid);
|
|
137
|
+
if (exact?.uuid) return exact.uuid;
|
|
138
|
+
}
|
|
139
|
+
// Saved message is gone (deleted). Pick the next-older entry by uid —
|
|
140
|
+
// uid is roughly monotonic with arrival on IMAP/Gmail — and return ITS
|
|
141
|
+
// uuid so the restore stays uuid-keyed end to end.
|
|
142
|
+
let best: any = null;
|
|
131
143
|
for (const m of items) {
|
|
132
|
-
if (typeof m.uid !== "number") continue;
|
|
133
|
-
if (m.uid < saved && m.uid > best) best = m
|
|
144
|
+
if (typeof m.uid !== "number" || !m.uuid) continue;
|
|
145
|
+
if (m.uid < saved.uid && (!best || m.uid > best.uid)) best = m;
|
|
134
146
|
}
|
|
135
|
-
if (best
|
|
136
|
-
// No older entry — saved was the bottom of the list.
|
|
137
|
-
|
|
138
|
-
return typeof items[0]?.uid === "number" ? items[0].uid : null;
|
|
147
|
+
if (best) return best.uuid;
|
|
148
|
+
// No older entry — saved was the bottom. Snap to the top of the list.
|
|
149
|
+
return items[0]?.uuid || null;
|
|
139
150
|
}
|
|
140
151
|
|
|
141
152
|
/** Quick equality check — same UID set, same flag pattern, same total.
|
|
@@ -680,10 +691,10 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
|
|
|
680
691
|
totalMessages = cached.total;
|
|
681
692
|
state.setMessages(cached.items);
|
|
682
693
|
renderMessages(body, "", cached.items);
|
|
683
|
-
const
|
|
684
|
-
if (
|
|
694
|
+
const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
|
|
695
|
+
if (targetUuid) {
|
|
685
696
|
body.scrollTop = savedScroll;
|
|
686
|
-
restoreSelection(body,
|
|
697
|
+
restoreSelection(body, targetUuid);
|
|
687
698
|
} else if (autoSelect) {
|
|
688
699
|
selectFirst(body);
|
|
689
700
|
}
|
|
@@ -711,10 +722,10 @@ export async function loadUnifiedInbox(autoSelect = true): Promise<void> {
|
|
|
711
722
|
state.setMessages(result.items);
|
|
712
723
|
renderMessages(body, "", result.items);
|
|
713
724
|
|
|
714
|
-
const
|
|
715
|
-
if (
|
|
725
|
+
const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
|
|
726
|
+
if (targetUuid) {
|
|
716
727
|
body.scrollTop = savedScroll;
|
|
717
|
-
restoreSelection(body,
|
|
728
|
+
restoreSelection(body, targetUuid);
|
|
718
729
|
} else if (autoSelect) {
|
|
719
730
|
selectFirst(body);
|
|
720
731
|
}
|
|
@@ -858,11 +869,11 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
|
|
|
858
869
|
totalMessages = cached.total;
|
|
859
870
|
state.setMessages(cached.items);
|
|
860
871
|
renderMessages(body, accountId, cached.items);
|
|
861
|
-
const
|
|
862
|
-
if (
|
|
872
|
+
const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
|
|
873
|
+
if (targetUuid) {
|
|
863
874
|
requestAnimationFrame(() => {
|
|
864
875
|
body.scrollTop = savedScroll;
|
|
865
|
-
restoreSelection(body,
|
|
876
|
+
restoreSelection(body, targetUuid);
|
|
866
877
|
});
|
|
867
878
|
} else if (autoSelect) {
|
|
868
879
|
selectFirst(body);
|
|
@@ -893,12 +904,12 @@ export async function loadMessages(accountId: string, folderId: number, page = 1
|
|
|
893
904
|
renderMessages(body, accountId, result.items);
|
|
894
905
|
|
|
895
906
|
// Prefer saved position; otherwise default by autoSelect.
|
|
896
|
-
const
|
|
897
|
-
if (
|
|
907
|
+
const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
|
|
908
|
+
if (targetUuid) {
|
|
898
909
|
requestAnimationFrame(() => {
|
|
899
910
|
if (myGen !== loadGen) return;
|
|
900
911
|
body.scrollTop = savedScroll;
|
|
901
|
-
restoreSelection(body,
|
|
912
|
+
restoreSelection(body, targetUuid);
|
|
902
913
|
});
|
|
903
914
|
} else if (autoSelect) {
|
|
904
915
|
selectFirst(body);
|
|
@@ -957,15 +968,19 @@ function selectFirst(body: HTMLElement): void {
|
|
|
957
968
|
if (firstRow) firstRow.click();
|
|
958
969
|
}
|
|
959
970
|
|
|
960
|
-
function restoreSelection(body: HTMLElement,
|
|
961
|
-
if (!
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
971
|
+
function restoreSelection(body: HTMLElement, savedUuid: string | null | undefined): void {
|
|
972
|
+
if (!savedUuid) return;
|
|
973
|
+
// Locate the row by stable uuid — unique, so this is the exact message
|
|
974
|
+
// the user had selected, never a uid-number collision in another folder.
|
|
975
|
+
const row = body.querySelector(`.ml-row[data-uuid="${CSS.escape(savedUuid)}"]`) as HTMLElement | null;
|
|
976
|
+
if (!row) return;
|
|
977
|
+
const accountId = row.dataset.accountId;
|
|
978
|
+
const uid = Number(row.dataset.uid);
|
|
979
|
+
if (accountId && Number.isFinite(uid)) {
|
|
980
|
+
// {scroll:false} — programmatic restore after a sync-driven reload.
|
|
981
|
+
// The user might be reading rows ABOVE the focused one; do NOT yank
|
|
982
|
+
// their scroll back to the focused row every time the list rebuilds.
|
|
983
|
+
focusByIdentity(accountId, uid, { scroll: false });
|
|
969
984
|
}
|
|
970
985
|
}
|
|
971
986
|
|
|
@@ -1081,6 +1096,12 @@ class MessageRow {
|
|
|
1081
1096
|
row.dataset.uid = String(msg.uid);
|
|
1082
1097
|
row.dataset.accountId = accountId;
|
|
1083
1098
|
row.dataset.folderId = String(msg.folderId);
|
|
1099
|
+
// Stable local identity. `uid` is only unique within (account, folder)
|
|
1100
|
+
// — in a unified / multi-folder list two messages can share a uid
|
|
1101
|
+
// NUMBER, so selection memory keyed on bare uid can rebind the viewer
|
|
1102
|
+
// to the wrong letter after a re-render. `uuid` is globally unique
|
|
1103
|
+
// and never changes; selection restore keys on it.
|
|
1104
|
+
if (msg.uuid) row.dataset.uuid = msg.uuid;
|
|
1084
1105
|
if (msg.threadId) row.dataset.threadId = msg.threadId;
|
|
1085
1106
|
if (threadHead) row.classList.add("thread-head");
|
|
1086
1107
|
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** View tabs — multiple views over one mailbox / one backend.
|
|
2
|
+
*
|
|
3
|
+
* See docs/multi-view.md. A tab is a self-contained *view descriptor*: which
|
|
4
|
+
* folder / unified-inbox / search it shows. The expensive things (DB, the
|
|
5
|
+
* live sync stream, folder counts, contacts) stay shared in the daemon and
|
|
6
|
+
* the one IPC channel — a tab is just the cheap "which folder, restored how"
|
|
7
|
+
* bundle. That bundle is also exactly what a future tear-off window needs,
|
|
8
|
+
* so this module is the seam for Stage 3.
|
|
9
|
+
*
|
|
10
|
+
* Stage 1: snapshot/restore. The DOM has ONE three-pane; switching tabs
|
|
11
|
+
* re-invokes the existing load functions for the target tab's view, and the
|
|
12
|
+
* list's own `positionMemory` restores selection + scroll per view. Inactive
|
|
13
|
+
* tabs hold only their descriptor — no detached DOM, no second list state.
|
|
14
|
+
*/
|
|
15
|
+
const STORAGE_KEY = "mailx-view-tabs";
|
|
16
|
+
let tabs = [];
|
|
17
|
+
let activeId = "";
|
|
18
|
+
let stripEl = null;
|
|
19
|
+
/** Applies a tab's view to the single three-pane. Set by initTabs; defined in
|
|
20
|
+
* app.ts because it must touch app-level view state + the load functions. */
|
|
21
|
+
let applyView = () => { };
|
|
22
|
+
let _nextId = 1;
|
|
23
|
+
function newId() { return `t${_nextId++}`; }
|
|
24
|
+
function persist() {
|
|
25
|
+
try {
|
|
26
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ tabs, activeId }));
|
|
27
|
+
}
|
|
28
|
+
catch { /* sessionStorage unavailable — tabs just won't survive reload */ }
|
|
29
|
+
}
|
|
30
|
+
function render() {
|
|
31
|
+
if (!stripEl)
|
|
32
|
+
return;
|
|
33
|
+
stripEl.innerHTML = "";
|
|
34
|
+
for (const tab of tabs) {
|
|
35
|
+
const chip = document.createElement("div");
|
|
36
|
+
chip.className = "view-tab" + (tab.id === activeId ? " active" : "");
|
|
37
|
+
chip.dataset.tabId = tab.id;
|
|
38
|
+
const label = document.createElement("span");
|
|
39
|
+
label.className = "view-tab-label";
|
|
40
|
+
label.textContent = tab.title || "(view)";
|
|
41
|
+
chip.appendChild(label);
|
|
42
|
+
// Close affordance — hidden when only one tab remains (can't close
|
|
43
|
+
// the last one; the window always shows something).
|
|
44
|
+
if (tabs.length > 1) {
|
|
45
|
+
const close = document.createElement("button");
|
|
46
|
+
close.className = "view-tab-close";
|
|
47
|
+
close.textContent = "×";
|
|
48
|
+
close.title = "Close tab";
|
|
49
|
+
close.addEventListener("click", (e) => { e.stopPropagation(); closeTab(tab.id); });
|
|
50
|
+
chip.appendChild(close);
|
|
51
|
+
}
|
|
52
|
+
chip.addEventListener("click", () => activate(tab.id));
|
|
53
|
+
stripEl.appendChild(chip);
|
|
54
|
+
}
|
|
55
|
+
const plus = document.createElement("button");
|
|
56
|
+
plus.className = "view-tab-new";
|
|
57
|
+
plus.textContent = "+";
|
|
58
|
+
plus.title = "New tab (All Inboxes)";
|
|
59
|
+
plus.addEventListener("click", () => openTab({ kind: "unified" }, "All Inboxes", true));
|
|
60
|
+
stripEl.appendChild(plus);
|
|
61
|
+
// Strip is visible whenever a tab exists — the "+" must stay reachable so
|
|
62
|
+
// the user can open a second view (browser model: one tab still shows a
|
|
63
|
+
// tab bar). Hidden only in the brief pre-first-selection boot window.
|
|
64
|
+
stripEl.hidden = tabs.length < 1;
|
|
65
|
+
}
|
|
66
|
+
/** Wire the strip. `apply` is the app.ts callback that loads a tab's view. */
|
|
67
|
+
export function initTabs(strip, apply) {
|
|
68
|
+
stripEl = strip;
|
|
69
|
+
applyView = apply;
|
|
70
|
+
try {
|
|
71
|
+
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
72
|
+
if (raw) {
|
|
73
|
+
const parsed = JSON.parse(raw);
|
|
74
|
+
if (Array.isArray(parsed?.tabs) && parsed.tabs.length) {
|
|
75
|
+
tabs = parsed.tabs;
|
|
76
|
+
activeId = parsed.activeId || tabs[0].id;
|
|
77
|
+
for (const t of tabs) {
|
|
78
|
+
const n = Number(String(t.id).replace(/^t/, ""));
|
|
79
|
+
if (Number.isFinite(n) && n >= _nextId)
|
|
80
|
+
_nextId = n + 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch { /* corrupt — start fresh */ }
|
|
86
|
+
render();
|
|
87
|
+
}
|
|
88
|
+
/** The currently-active tab, or null before any tab exists. */
|
|
89
|
+
export function activeTab() {
|
|
90
|
+
return tabs.find(t => t.id === activeId) || null;
|
|
91
|
+
}
|
|
92
|
+
/** Open a new tab for `view` and (by default) switch to it. */
|
|
93
|
+
export function openTab(view, title, activateIt = true) {
|
|
94
|
+
const tab = { id: newId(), title, view };
|
|
95
|
+
tabs.push(tab);
|
|
96
|
+
if (activateIt)
|
|
97
|
+
activeId = tab.id;
|
|
98
|
+
persist();
|
|
99
|
+
render();
|
|
100
|
+
if (activateIt)
|
|
101
|
+
applyView(tab);
|
|
102
|
+
}
|
|
103
|
+
/** Switch to an existing tab and load its view. */
|
|
104
|
+
export function activate(id) {
|
|
105
|
+
const tab = tabs.find(t => t.id === id);
|
|
106
|
+
if (!tab || id === activeId)
|
|
107
|
+
return;
|
|
108
|
+
activeId = id;
|
|
109
|
+
persist();
|
|
110
|
+
render();
|
|
111
|
+
applyView(tab);
|
|
112
|
+
}
|
|
113
|
+
/** Close a tab. Never closes the last one. If the active tab is closed, the
|
|
114
|
+
* neighbour to its left (or right) becomes active. */
|
|
115
|
+
export function closeTab(id) {
|
|
116
|
+
if (tabs.length < 2)
|
|
117
|
+
return;
|
|
118
|
+
const idx = tabs.findIndex(t => t.id === id);
|
|
119
|
+
if (idx < 0)
|
|
120
|
+
return;
|
|
121
|
+
const wasActive = id === activeId;
|
|
122
|
+
tabs.splice(idx, 1);
|
|
123
|
+
if (wasActive) {
|
|
124
|
+
const next = tabs[Math.max(0, idx - 1)];
|
|
125
|
+
activeId = next.id;
|
|
126
|
+
persist();
|
|
127
|
+
render();
|
|
128
|
+
applyView(next);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
persist();
|
|
132
|
+
render();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Record the view the user just navigated to in the CURRENT tab — called
|
|
136
|
+
* from the folder-tree / search handlers. Does NOT re-apply the view (the
|
|
137
|
+
* navigation already loaded it); only keeps the active tab's descriptor +
|
|
138
|
+
* title in sync so a later tab-switch restores the right thing. If no tab
|
|
139
|
+
* exists yet (first navigation after boot), this creates the first tab —
|
|
140
|
+
* so the very first folder/inbox selection seeds the strip. */
|
|
141
|
+
export function setActiveView(view, title) {
|
|
142
|
+
let tab = activeTab();
|
|
143
|
+
if (!tab) {
|
|
144
|
+
tab = { id: newId(), title, view };
|
|
145
|
+
tabs.push(tab);
|
|
146
|
+
activeId = tab.id;
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
tab.view = view;
|
|
150
|
+
tab.title = title;
|
|
151
|
+
}
|
|
152
|
+
persist();
|
|
153
|
+
render();
|
|
154
|
+
}
|
|
155
|
+
//# sourceMappingURL=tabs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tabs.js","sourceRoot":"","sources":["tabs.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/** View tabs — multiple views over one mailbox / one backend.
|
|
2
|
+
*
|
|
3
|
+
* See docs/multi-view.md. A tab is a self-contained *view descriptor*: which
|
|
4
|
+
* folder / unified-inbox / search it shows. The expensive things (DB, the
|
|
5
|
+
* live sync stream, folder counts, contacts) stay shared in the daemon and
|
|
6
|
+
* the one IPC channel — a tab is just the cheap "which folder, restored how"
|
|
7
|
+
* bundle. That bundle is also exactly what a future tear-off window needs,
|
|
8
|
+
* so this module is the seam for Stage 3.
|
|
9
|
+
*
|
|
10
|
+
* Stage 1: snapshot/restore. The DOM has ONE three-pane; switching tabs
|
|
11
|
+
* re-invokes the existing load functions for the target tab's view, and the
|
|
12
|
+
* list's own `positionMemory` restores selection + scroll per view. Inactive
|
|
13
|
+
* tabs hold only their descriptor — no detached DOM, no second list state.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type TabView =
|
|
17
|
+
| { kind: "unified" }
|
|
18
|
+
| { kind: "folder"; accountId: string; folderId: number; specialUse: string }
|
|
19
|
+
| { kind: "search"; query: string; scope: string; accountId: string; folderId: number; includeTrash: boolean };
|
|
20
|
+
|
|
21
|
+
export interface ViewTab {
|
|
22
|
+
id: string;
|
|
23
|
+
title: string;
|
|
24
|
+
view: TabView;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const STORAGE_KEY = "mailx-view-tabs";
|
|
28
|
+
let tabs: ViewTab[] = [];
|
|
29
|
+
let activeId = "";
|
|
30
|
+
let stripEl: HTMLElement | null = null;
|
|
31
|
+
/** Applies a tab's view to the single three-pane. Set by initTabs; defined in
|
|
32
|
+
* app.ts because it must touch app-level view state + the load functions. */
|
|
33
|
+
let applyView: (tab: ViewTab) => void = () => { /* */ };
|
|
34
|
+
let _nextId = 1;
|
|
35
|
+
function newId(): string { return `t${_nextId++}`; }
|
|
36
|
+
|
|
37
|
+
function persist(): void {
|
|
38
|
+
try {
|
|
39
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ tabs, activeId }));
|
|
40
|
+
} catch { /* sessionStorage unavailable — tabs just won't survive reload */ }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function render(): void {
|
|
44
|
+
if (!stripEl) return;
|
|
45
|
+
stripEl.innerHTML = "";
|
|
46
|
+
for (const tab of tabs) {
|
|
47
|
+
const chip = document.createElement("div");
|
|
48
|
+
chip.className = "view-tab" + (tab.id === activeId ? " active" : "");
|
|
49
|
+
chip.dataset.tabId = tab.id;
|
|
50
|
+
const label = document.createElement("span");
|
|
51
|
+
label.className = "view-tab-label";
|
|
52
|
+
label.textContent = tab.title || "(view)";
|
|
53
|
+
chip.appendChild(label);
|
|
54
|
+
// Close affordance — hidden when only one tab remains (can't close
|
|
55
|
+
// the last one; the window always shows something).
|
|
56
|
+
if (tabs.length > 1) {
|
|
57
|
+
const close = document.createElement("button");
|
|
58
|
+
close.className = "view-tab-close";
|
|
59
|
+
close.textContent = "×";
|
|
60
|
+
close.title = "Close tab";
|
|
61
|
+
close.addEventListener("click", (e) => { e.stopPropagation(); closeTab(tab.id); });
|
|
62
|
+
chip.appendChild(close);
|
|
63
|
+
}
|
|
64
|
+
chip.addEventListener("click", () => activate(tab.id));
|
|
65
|
+
stripEl.appendChild(chip);
|
|
66
|
+
}
|
|
67
|
+
const plus = document.createElement("button");
|
|
68
|
+
plus.className = "view-tab-new";
|
|
69
|
+
plus.textContent = "+";
|
|
70
|
+
plus.title = "New tab (All Inboxes)";
|
|
71
|
+
plus.addEventListener("click", () => openTab({ kind: "unified" }, "All Inboxes", true));
|
|
72
|
+
stripEl.appendChild(plus);
|
|
73
|
+
// Strip is visible whenever a tab exists — the "+" must stay reachable so
|
|
74
|
+
// the user can open a second view (browser model: one tab still shows a
|
|
75
|
+
// tab bar). Hidden only in the brief pre-first-selection boot window.
|
|
76
|
+
stripEl.hidden = tabs.length < 1;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Wire the strip. `apply` is the app.ts callback that loads a tab's view. */
|
|
80
|
+
export function initTabs(strip: HTMLElement, apply: (tab: ViewTab) => void): void {
|
|
81
|
+
stripEl = strip;
|
|
82
|
+
applyView = apply;
|
|
83
|
+
try {
|
|
84
|
+
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
85
|
+
if (raw) {
|
|
86
|
+
const parsed = JSON.parse(raw) as { tabs: ViewTab[]; activeId: string };
|
|
87
|
+
if (Array.isArray(parsed?.tabs) && parsed.tabs.length) {
|
|
88
|
+
tabs = parsed.tabs;
|
|
89
|
+
activeId = parsed.activeId || tabs[0].id;
|
|
90
|
+
for (const t of tabs) {
|
|
91
|
+
const n = Number(String(t.id).replace(/^t/, ""));
|
|
92
|
+
if (Number.isFinite(n) && n >= _nextId) _nextId = n + 1;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch { /* corrupt — start fresh */ }
|
|
97
|
+
render();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The currently-active tab, or null before any tab exists. */
|
|
101
|
+
export function activeTab(): ViewTab | null {
|
|
102
|
+
return tabs.find(t => t.id === activeId) || null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Open a new tab for `view` and (by default) switch to it. */
|
|
106
|
+
export function openTab(view: TabView, title: string, activateIt = true): void {
|
|
107
|
+
const tab: ViewTab = { id: newId(), title, view };
|
|
108
|
+
tabs.push(tab);
|
|
109
|
+
if (activateIt) activeId = tab.id;
|
|
110
|
+
persist();
|
|
111
|
+
render();
|
|
112
|
+
if (activateIt) applyView(tab);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Switch to an existing tab and load its view. */
|
|
116
|
+
export function activate(id: string): void {
|
|
117
|
+
const tab = tabs.find(t => t.id === id);
|
|
118
|
+
if (!tab || id === activeId) return;
|
|
119
|
+
activeId = id;
|
|
120
|
+
persist();
|
|
121
|
+
render();
|
|
122
|
+
applyView(tab);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Close a tab. Never closes the last one. If the active tab is closed, the
|
|
126
|
+
* neighbour to its left (or right) becomes active. */
|
|
127
|
+
export function closeTab(id: string): void {
|
|
128
|
+
if (tabs.length < 2) return;
|
|
129
|
+
const idx = tabs.findIndex(t => t.id === id);
|
|
130
|
+
if (idx < 0) return;
|
|
131
|
+
const wasActive = id === activeId;
|
|
132
|
+
tabs.splice(idx, 1);
|
|
133
|
+
if (wasActive) {
|
|
134
|
+
const next = tabs[Math.max(0, idx - 1)];
|
|
135
|
+
activeId = next.id;
|
|
136
|
+
persist();
|
|
137
|
+
render();
|
|
138
|
+
applyView(next);
|
|
139
|
+
} else {
|
|
140
|
+
persist();
|
|
141
|
+
render();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Record the view the user just navigated to in the CURRENT tab — called
|
|
146
|
+
* from the folder-tree / search handlers. Does NOT re-apply the view (the
|
|
147
|
+
* navigation already loaded it); only keeps the active tab's descriptor +
|
|
148
|
+
* title in sync so a later tab-switch restores the right thing. If no tab
|
|
149
|
+
* exists yet (first navigation after boot), this creates the first tab —
|
|
150
|
+
* so the very first folder/inbox selection seeds the strip. */
|
|
151
|
+
export function setActiveView(view: TabView, title: string): void {
|
|
152
|
+
let tab = activeTab();
|
|
153
|
+
if (!tab) {
|
|
154
|
+
tab = { id: newId(), title, view };
|
|
155
|
+
tabs.push(tab);
|
|
156
|
+
activeId = tab.id;
|
|
157
|
+
} else {
|
|
158
|
+
tab.view = view;
|
|
159
|
+
tab.title = title;
|
|
160
|
+
}
|
|
161
|
+
persist();
|
|
162
|
+
render();
|
|
163
|
+
}
|
package/client/index.html
CHANGED
|
@@ -794,6 +794,10 @@
|
|
|
794
794
|
<button class="alert-dismiss" id="alert-dismiss" title="Dismiss">×</button>
|
|
795
795
|
</div>
|
|
796
796
|
|
|
797
|
+
<!-- View tabs — full-width bar above all three panes. A tab is the whole
|
|
798
|
+
three-pane view, so the strip spans the window (see docs/multi-view.md). -->
|
|
799
|
+
<div class="view-tab-strip" id="view-tab-strip" hidden></div>
|
|
800
|
+
|
|
797
801
|
<aside class="icon-rail" id="icon-rail" aria-label="App rail">
|
|
798
802
|
<div class="rail-top">
|
|
799
803
|
<button class="rail-btn" id="rail-compose" title="Compose (Ctrl+N)" aria-label="Compose">✏</button>
|
|
@@ -146,6 +146,64 @@ button.tb-menu-item { background: none; border: none; color: inherit; width: 100
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/* View tabs — see docs/multi-view.md. Hidden until a 2nd tab exists. */
|
|
150
|
+
.view-tab-strip {
|
|
151
|
+
display: flex;
|
|
152
|
+
align-items: stretch;
|
|
153
|
+
gap: 2px;
|
|
154
|
+
padding: 2px 4px 0;
|
|
155
|
+
overflow-x: auto;
|
|
156
|
+
background: var(--color-bg-subtle, #f0f0f0);
|
|
157
|
+
border-bottom: 1px solid var(--color-border, #ccc);
|
|
158
|
+
|
|
159
|
+
& .view-tab {
|
|
160
|
+
display: flex;
|
|
161
|
+
align-items: center;
|
|
162
|
+
gap: 4px;
|
|
163
|
+
padding: 4px 8px;
|
|
164
|
+
max-width: 200px;
|
|
165
|
+
font-size: 0.85em;
|
|
166
|
+
cursor: pointer;
|
|
167
|
+
border: 1px solid transparent;
|
|
168
|
+
border-bottom: none;
|
|
169
|
+
border-radius: 5px 5px 0 0;
|
|
170
|
+
white-space: nowrap;
|
|
171
|
+
color: var(--color-text-muted, #555);
|
|
172
|
+
|
|
173
|
+
& .view-tab-label { overflow: hidden; text-overflow: ellipsis; }
|
|
174
|
+
|
|
175
|
+
&.active {
|
|
176
|
+
background: var(--color-bg, #fff);
|
|
177
|
+
border-color: var(--color-border, #ccc);
|
|
178
|
+
color: var(--color-text, #000);
|
|
179
|
+
font-weight: 600;
|
|
180
|
+
}
|
|
181
|
+
&:not(.active):hover { background: var(--color-hover, #e4e4e4); }
|
|
182
|
+
|
|
183
|
+
& .view-tab-close {
|
|
184
|
+
border: none;
|
|
185
|
+
background: none;
|
|
186
|
+
cursor: pointer;
|
|
187
|
+
font-size: 1.1em;
|
|
188
|
+
line-height: 1;
|
|
189
|
+
padding: 0 2px;
|
|
190
|
+
color: inherit;
|
|
191
|
+
opacity: 0.6;
|
|
192
|
+
}
|
|
193
|
+
& .view-tab-close:hover { opacity: 1; }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
& .view-tab-new {
|
|
197
|
+
border: none;
|
|
198
|
+
background: none;
|
|
199
|
+
cursor: pointer;
|
|
200
|
+
font-size: 1.1em;
|
|
201
|
+
padding: 0 8px;
|
|
202
|
+
color: var(--color-text-muted, #555);
|
|
203
|
+
}
|
|
204
|
+
& .view-tab-new:hover { color: var(--color-text, #000); }
|
|
205
|
+
}
|
|
206
|
+
|
|
149
207
|
.search-bar {
|
|
150
208
|
display: flex;
|
|
151
209
|
flex-wrap: wrap; /* Server / Trash-Spam checkboxes drop to a second row when the column is too narrow to fit them inline */
|
package/client/styles/layout.css
CHANGED
|
@@ -10,12 +10,13 @@ body {
|
|
|
10
10
|
display: grid;
|
|
11
11
|
/* rail | folders | main */
|
|
12
12
|
grid-template-columns: var(--rail-width, 48px) var(--folder-width) 1fr;
|
|
13
|
-
grid-template-rows: var(--toolbar-height) auto 1fr var(--statusbar-height);
|
|
13
|
+
grid-template-rows: var(--toolbar-height) auto auto 1fr var(--statusbar-height);
|
|
14
14
|
grid-template-areas:
|
|
15
|
-
"toolbar
|
|
16
|
-
"alert
|
|
17
|
-
"
|
|
18
|
-
"
|
|
15
|
+
"toolbar toolbar toolbar"
|
|
16
|
+
"alert alert alert"
|
|
17
|
+
"viewtabs viewtabs viewtabs"
|
|
18
|
+
"rail folders main"
|
|
19
|
+
"status status status";
|
|
19
20
|
height: 100vh;
|
|
20
21
|
overflow: hidden;
|
|
21
22
|
font-family: var(--font-ui);
|
|
@@ -32,6 +33,7 @@ body.fullscreen-preview > .toolbar,
|
|
|
32
33
|
body.fullscreen-preview > .icon-rail,
|
|
33
34
|
body.fullscreen-preview > .folder-panel,
|
|
34
35
|
body.fullscreen-preview > .alert-banner,
|
|
36
|
+
body.fullscreen-preview > .view-tab-strip,
|
|
35
37
|
body.fullscreen-preview > .status-bar,
|
|
36
38
|
body.fullscreen-preview .message-list,
|
|
37
39
|
body.fullscreen-preview .ml-toolbar,
|
|
@@ -50,10 +52,11 @@ body.fullscreen-preview {
|
|
|
50
52
|
body.calendar-sidebar-on {
|
|
51
53
|
grid-template-columns: var(--rail-width, 48px) var(--folder-width) 1fr var(--cal-side-width, 280px);
|
|
52
54
|
grid-template-areas:
|
|
53
|
-
"toolbar
|
|
54
|
-
"alert
|
|
55
|
-
"
|
|
56
|
-
"
|
|
55
|
+
"toolbar toolbar toolbar toolbar"
|
|
56
|
+
"alert alert alert alert"
|
|
57
|
+
"viewtabs viewtabs viewtabs viewtabs"
|
|
58
|
+
"rail folders main cal-side"
|
|
59
|
+
"status status status status";
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
.toolbar { grid-area: toolbar; }
|
|
@@ -62,6 +65,9 @@ body.calendar-sidebar-on {
|
|
|
62
65
|
.folder-tree { flex: 1; overflow-y: auto; }
|
|
63
66
|
.main-area { grid-area: main; }
|
|
64
67
|
.status-bar { grid-area: status; }
|
|
68
|
+
/* View-tabs bar — full-width row above all panes. When hidden the `auto`
|
|
69
|
+
row collapses to 0, so nothing changes for single-tab use. */
|
|
70
|
+
.view-tab-strip { grid-area: viewtabs; }
|
|
65
71
|
|
|
66
72
|
/* Vertical icon rail — Thunderbird Supernova style: dark background with
|
|
67
73
|
light icons so the rail reads as "chrome" and contrasts visibly against
|