@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.
@@ -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
- positionMemory.set(key, { uid, scroll: body.scrollTop });
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: number): number | null {
130
+ function pickRestoreUid(items: any[], saved: ViewPosition): string | null {
126
131
  if (!items.length) return null;
127
- if (items.some(m => m.uid === saved)) return saved;
128
- // Next-older entry: largest remaining uid that is < saved. Stable
129
- // even if list is unsorted by uid (loops through all rows).
130
- let best = -1;
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.uid;
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 >= 0) return best;
136
- // No older entry — saved was the bottom of the list. Snap to the
137
- // first row that sorts "after" via date-desc tie-break (top of list).
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 targetUid = remembered ? pickRestoreUid(cached.items, remembered.uid) : null;
684
- if (targetUid != null) {
694
+ const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
695
+ if (targetUuid) {
685
696
  body.scrollTop = savedScroll;
686
- restoreSelection(body, String(targetUid));
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 targetUid = remembered ? pickRestoreUid(result.items, remembered.uid) : null;
715
- if (targetUid != null) {
725
+ const targetUuid = remembered ? pickRestoreUid(result.items, remembered) : null;
726
+ if (targetUuid) {
716
727
  body.scrollTop = savedScroll;
717
- restoreSelection(body, String(targetUid));
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 targetUid = remembered ? pickRestoreUid(cached.items, remembered.uid) : null;
862
- if (targetUid != null) {
872
+ const targetUuid = remembered ? pickRestoreUid(cached.items, remembered) : null;
873
+ if (targetUuid) {
863
874
  requestAnimationFrame(() => {
864
875
  body.scrollTop = savedScroll;
865
- restoreSelection(body, String(targetUid));
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 targetUid = remembered ? pickRestoreUid(result.items, remembered.uid) : null;
897
- if (targetUid != null) {
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, String(targetUid));
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, savedUid: string | null | undefined): void {
961
- if (!savedUid) return;
962
- const accountId = (body.querySelector(`.ml-row[data-uid="${savedUid}"]`) as HTMLElement)?.dataset.accountId;
963
- if (accountId) {
964
- // {scroll:false} this is a programmatic restore after a sync-driven
965
- // reload. The user might be reading rows ABOVE the focused one; do
966
- // NOT yank their scroll back to the focused row every time the list
967
- // rebuilds.
968
- focusByIdentity(accountId, Number(savedUid), { scroll: false });
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">&times;</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 */
@@ -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 toolbar toolbar"
16
- "alert alert alert"
17
- "rail folders main"
18
- "status status status";
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 toolbar toolbar toolbar"
54
- "alert alert alert alert"
55
- "rail folders main cal-side"
56
- "status status status status";
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