@bobfrankston/rmfmail 1.1.36 → 1.1.37

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
@@ -821,6 +821,7 @@
821
821
 
822
822
  <main class="main-area">
823
823
  <section class="message-list" id="message-list">
824
+ <div class="view-tab-strip" id="view-tab-strip" hidden></div>
824
825
  <search class="search-bar ml-search">
825
826
  <select id="search-scope" title="Local search scope">
826
827
  <option value="all">All folders</option>
@@ -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 */
@@ -0,0 +1,81 @@
1
+ # Multi-view: tabs, tear-off, separate windows
2
+
3
+ Goal: multiple **views** over one backend — like Thunderbird/Outlook — so the
4
+ user can keep a message open while working the list, compare two folders, etc.
5
+ NOT multiple daemon instances (`--another` already exists for that, and a
6
+ second daemon means a second DB connection — contention, not what's wanted).
7
+
8
+ ## Key fact: reminders already spawn separate native windows
9
+
10
+ `MailxService.showReminderPopup` → injected `popupFn` → msger
11
+ `showMessageBoxEx` opens a **real OS WebView window** fed a self-contained HTML
12
+ document (`rawHtml: true`: the caller ships a full HTML doc with its own button
13
+ row + an inline script that posts results back over the wry bridge). It returns
14
+ a handle with `pid` + `close()`.
15
+
16
+ Consequence: msger spawning extra native windows is **not** the blocker. The
17
+ "popups don't inherit the custom protocol" limitation only blocks a window that
18
+ must load the *multi-file app* (index.html + app.bundle.js + CSS + importmap
19
+ deps, all served from `msger.localhost`). A **single rendered view** is
20
+ self-contained content — the reminder shape.
21
+
22
+ So:
23
+ - **Torn-off READ-ONLY message reader** = the reminder mechanism. The daemon
24
+ already has rendered `bodyHtml` from `getMessage`; wrap header + sanitized
25
+ body as a standalone HTML doc, hand to `showMessageBoxEx`. No custom
26
+ protocol, no second IPC channel. Cheap.
27
+ - **Torn-off INTERACTIVE view** (reply / navigate / delete / live sync
28
+ updates) needs a real IPC channel back to the daemon → msger multi-channel
29
+ work. Expensive. Deferred.
30
+
31
+ ## Three view modes, one backend
32
+
33
+ 1. **Tabs** inside the main window — one WebView, one IPC channel, a tab strip;
34
+ each tab is an independent three-pane (or a single-message view). Pure
35
+ client work.
36
+ 2. **Tear-off** — detach a tab into its own OS window.
37
+ - Read-only message tab → `showMessageBoxEx` + `rawHtml` (reminder path).
38
+ - Interactive tab → needs msger multi-channel IPC (later).
39
+ 3. **Re-dock** — drag a torn-off window's content back in as a tab. For the
40
+ read-only reader this is just: close the popup, re-open the message as an
41
+ in-window tab. For interactive, follows whatever (2) settles on.
42
+
43
+ ## Tabs architecture (the real work)
44
+
45
+ Today the list view is module-level global state in `client/components/
46
+ message-list.ts` — `currentAccountId`, `currentFolderId`, `searchMode`,
47
+ `currentSearchQuery`, `positionMemory`, `listCache` — and `message-state.ts` is
48
+ a single global "source of truth" for the list+viewer. One window = one view.
49
+
50
+ Tabs require that view-state to become **per-tab**:
51
+
52
+ - **Per-tab:** selected account+folder (or search), selected message, list
53
+ scroll position, the `message-state` instance, sort order, flagged-only
54
+ toggle.
55
+ - **Shared (stays global):** the IPC connection + sync event stream, folder
56
+ counts, the contacts cache, settings, the parsed-body LRU on the daemon.
57
+ Sync/new-mail events broadcast to every tab; each tab decides if the event
58
+ touches its folder.
59
+
60
+ Shape: a `TabManager` owning an array of `ViewTab`, each `ViewTab` holding its
61
+ own `MessageListState` + the DOM subtree for its three-pane. Only the active
62
+ tab's subtree is in layout; inactive tabs keep their DOM detached (cheap) or
63
+ their state and rebuild on activate. `message-list.ts` functions take a
64
+ `ViewTab` (or read `TabManager.active`) instead of the module globals.
65
+
66
+ ## Staging
67
+
68
+ 1. **Tab strip + per-tab three-pane.** Extract the message-list globals into a
69
+ `ViewTab`/`TabManager`; render a tab strip; New Tab opens another inbox
70
+ view. The bulk of the work and the architectural commit.
71
+ 2. **Open-in-new-tab** for a message (and a folder). Exercises per-tab state on
72
+ the simplest views.
73
+ 3. **Tear-off a read-only message tab** → `showMessageBoxEx` standalone window
74
+ (reminder mechanism). Re-dock = reopen as a tab.
75
+ 4. **Interactive tear-off / true second app window.** Needs msger to let a
76
+ spawned window inherit the custom protocol AND carry its own IPC channel,
77
+ plus the daemon multiplexing IPC and broadcasting events to N channels.
78
+ Separate, cross-component project — do last, only if the read-only
79
+ tear-off proves insufficient.
80
+
81
+ Start at stage 1. Each stage is independently shippable.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/rmfmail",
3
- "version": "1.1.36",
3
+ "version": "1.1.37",
4
4
  "description": "Local-first email client with IMAP sync and standalone native app",
5
5
  "type": "module",
6
6
  "main": "bin/mailx.js",
@@ -77,7 +77,8 @@
77
77
  "author": "Bob Frankston",
78
78
  "license": "MIT",
79
79
  "publishConfig": {
80
- "access": "public"
80
+ "access": "public",
81
+ "tag": "dev"
81
82
  },
82
83
  "repository": {
83
84
  "type": "git",