app_browser_launch_test.go RawHistoryFolder

1package ui
2
3import (
4	"bytes"
5	"context"
6	"encoding/binary"
7	"errors"
8	"io"
9	"math"
10	"net"
11	"os"
12	"path/filepath"
13	"reflect"
14	"slices"
15	"strconv"
16	"strings"
17	"testing"
18	"time"
19	"unsafe"
20
21	"github.com/bnema/dumber/internal/application/port"
22	portmocks "github.com/bnema/dumber/internal/application/port/mocks"
23	"github.com/bnema/dumber/internal/application/usecase"
24	"github.com/bnema/dumber/internal/domain/entity"
25	repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
26	"github.com/bnema/dumber/internal/infrastructure/config"
27	"github.com/bnema/dumber/internal/shared/syncdispatch"
28	"github.com/bnema/dumber/internal/ui/component"
29	"github.com/bnema/dumber/internal/ui/coordinator"
30	contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
31	"github.com/bnema/dumber/internal/ui/dispatcher"
32	"github.com/bnema/dumber/internal/ui/input"
33	"github.com/bnema/dumber/internal/ui/layout"
34	layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
35	"github.com/bnema/dumber/internal/ui/window"
36	"github.com/bnema/puregotk/v4/gdk"
37	"github.com/bnema/puregotk/v4/gio"
38	"github.com/bnema/puregotk/v4/glib"
39	"github.com/bnema/puregotk/v4/gtk"
40	"github.com/stretchr/testify/assert"
41	"github.com/stretchr/testify/mock"
42	"github.com/stretchr/testify/require"
43)
44
45type testBrowserLaunchRelay struct {
46	listenCalls int
47	closer      *testCloser
48}
49
50func (r *testBrowserLaunchRelay) DeliverOpenExternalURL(context.Context, string) (bool, error) {
51	return false, nil
52}
53
54func (r *testBrowserLaunchRelay) DeliverOpenFreshWindow(context.Context, string) (bool, error) {
55	return false, nil
56}
57
58func (r *testBrowserLaunchRelay) Listen(_ context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
59	r.listenCalls++
60	_ = opener
61	return r.closer, nil
62}
63
64type testCloser struct {
65	closed bool
66}
67
68func (c *testCloser) Close() error {
69	c.closed = true
70	return nil
71}
72
73func testPathIsSocket(path string) bool {
74	info, err := os.Stat(path)
75	return err == nil && info.Mode()&os.ModeSocket != 0
76}
77
78func mockSessionStateRepoWithSnapshot(t *testing.T, sessionID entity.SessionID, state *entity.SessionState) *repomocks.MockSessionStateRepository {
79	t.Helper()
80	repo := repomocks.NewMockSessionStateRepository(t)
81	repo.EXPECT().
82		GetSnapshot(mock.Anything, sessionID).
83		Return(state, nil).
84		Once()
85	return repo
86}
87
88func mockZoomRepo(t *testing.T) *repomocks.MockZoomRepository {
89	t.Helper()
90	repo := repomocks.NewMockZoomRepository(t)
91	repo.EXPECT().
92		Get(mock.Anything, mock.Anything).
93		Return((*entity.ZoomLevel)(nil), nil).
94		Maybe()
95	repo.EXPECT().
96		Set(mock.Anything, mock.Anything).
97		Return(nil).
98		Maybe()
99	return repo
100}
101
102func testGDKBackendAllows(backend string) bool {
103	configured := os.Getenv("GDK_BACKEND")
104	if configured == "" {
105		return true
106	}
107	for candidate := range strings.SplitSeq(configured, ",") {
108		if strings.TrimSpace(candidate) == backend {
109			return true
110		}
111	}
112	return false
113}
114
115func testHasUsableWaylandDisplay() bool {
116	if !testGDKBackendAllows("wayland") {
117		return false
118	}
119	waylandDisplay := os.Getenv("WAYLAND_DISPLAY")
120	if waylandDisplay == "" {
121		return false
122	}
123	candidates := []string{waylandDisplay}
124	if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" && !filepath.IsAbs(waylandDisplay) {
125		candidates = append([]string{filepath.Join(runtimeDir, waylandDisplay)}, candidates...)
126	}
127	return slices.ContainsFunc(candidates, testPathIsSocket)
128}
129
130func testHasUsableX11Display() bool {
131	if !testGDKBackendAllows("x11") {
132		return false
133	}
134	display := os.Getenv("DISPLAY")
135	if display == "" {
136		return false
137	}
138	if after, ok := strings.CutPrefix(display, ":"); ok {
139		displayNum := after
140		displayNum = strings.SplitN(displayNum, ".", 2)[0]
141		if displayNum == "" {
142			return false
143		}
144		x11SocketCandidates := []string{filepath.Join(os.TempDir(), ".X11-unix", "X"+displayNum)}
145		if fallback := "/tmp/.X11-unix/X" + displayNum; fallback != x11SocketCandidates[0] {
146			x11SocketCandidates = append(x11SocketCandidates, fallback)
147		}
148		return slices.ContainsFunc(x11SocketCandidates, testPathIsSocket)
149	}
150	return true // TCP display
151}
152
153func testHasUsableGTKDisplay() bool {
154	return testHasUsableWaylandDisplay() || testHasUsableX11Display()
155}
156
157// testHasX11Auth checks whether X11 authorization is available for the current
158// DISPLAY before any GTK/Adwaita initialization. It is a pure function that
159// inspects env vars and the .Xauthority file only; no GTK calls.
160func testHasX11Auth() bool {
161	display := os.Getenv("DISPLAY")
162	if display == "" || !strings.HasPrefix(display, ":") {
163		// No DISPLAY or TCP-style display; can't check locally.
164		// Let GTK try and fall through to gdk.DisplayGetDefault().
165		return true
166	}
167
168	displayNum := strings.TrimPrefix(display, ":")
169	displayNum = strings.SplitN(displayNum, ".", 2)[0]
170	if displayNum == "" {
171		return false
172	}
173
174	// Find the X authority file.
175	authFile := os.Getenv("XAUTHORITY")
176	if authFile == "" {
177		home, err := os.UserHomeDir()
178		if err != nil {
179			return false
180		}
181		authFile = filepath.Join(home, ".Xauthority")
182	}
183
184	if _, err := os.Stat(authFile); err != nil {
185		return false // No authority file; X11 auth will fail.
186	}
187
188	// Parse the .Xauthority file to look for a matching display entry.
189	// Format: sequence of {family(2B), addrLen(2B), addr, numLen(2B), num, nameLen(2B), name, dataLen(2B), data}
190	// All multi-byte values are big-endian (network byte order).
191	data, err := os.ReadFile(authFile)
192	if err != nil {
193		return false
194	}
195
196	return testXauthorityHasDisplay(data, displayNum)
197}
198
199func testXauthorityHasDisplay(data []byte, displayNum string) bool {
200	for len(data) > 0 {
201		if len(data) < 2 {
202			return false
203		}
204		data = data[2:] // family, unused
205
206		_, rest, ok := testReadXauthorityField(data) // address
207		if !ok {
208			return false
209		}
210		num, rest, ok := testReadXauthorityField(rest)
211		if !ok {
212			return false
213		}
214		name, rest, ok := testReadXauthorityField(rest)
215		if !ok {
216			return false
217		}
218		cookie, rest, ok := testReadXauthorityField(rest)
219		if !ok {
220			return false
221		}
222		if string(num) == displayNum && len(name) > 0 && len(cookie) > 0 {
223			return true
224		}
225		data = rest
226	}
227	return false
228}
229
230func testReadXauthorityField(data []byte) ([]byte, []byte, bool) {
231	if len(data) < 2 {
232		return nil, nil, false
233	}
234	fieldLen := int(binary.BigEndian.Uint16(data[:2]))
235	data = data[2:]
236	if len(data) < fieldLen {
237		return nil, nil, false
238	}
239	return data[:fieldLen], data[fieldLen:], true
240}
241
242// testNeedsX11Auth reports whether GTK may need the X11 backend and therefore
243// requires X11 authorization before GTK initialization. Wayland-only backend
244// configuration never needs X11 auth; X11 is required when it is the only usable
245// allowed backend.
246func testNeedsX11Auth() bool {
247	if !testGDKBackendAllows("x11") {
248		return false
249	}
250	return !testGDKBackendAllows("wayland") || !testHasUsableWaylandDisplay()
251}
252
253func testCreateUnixSocket(t *testing.T, path string) {
254	t.Helper()
255	if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
256		t.Fatal(err)
257	}
258	listener, err := net.Listen("unix", path)
259	if err != nil {
260		t.Fatal(err)
261	}
262	t.Cleanup(func() {
263		_ = listener.Close()
264		_ = os.Remove(path)
265	})
266}
267
268func testUnusedX11DisplayNumber(t *testing.T) string {
269	t.Helper()
270	for display := 90; display < 300; display++ {
271		displayNum := strconv.Itoa(display)
272		if !testPathIsSocket("/tmp/.X11-unix/X" + displayNum) {
273			return displayNum
274		}
275	}
276	t.Fatal("could not find an unused X11 display number")
277	return ""
278}
279
280func testCreateX11Socket(t *testing.T, displayNum string) {
281	t.Helper()
282	tmpDir := t.TempDir()
283	t.Setenv("TMPDIR", tmpDir)
284	testCreateUnixSocket(t, filepath.Join(tmpDir, ".X11-unix", "X"+displayNum))
285}
286
287func requireGTKDisplayApp(t *testing.T) *gtk.Application {
288	t.Helper()
289	hasWayland := testHasUsableWaylandDisplay()
290	hasX11 := testHasUsableX11Display()
291	if !hasWayland && !hasX11 {
292		t.Skip("GTK display not available (no DISPLAY/WAYLAND_DISPLAY socket)")
293	}
294	// Only require X11 auth when X11 is the only usable allowed backend.
295	if testNeedsX11Auth() && !testHasX11Auth() {
296		t.Skip("GTK display not available (X11 authorization unavailable)")
297	}
298	EnsureAdwaitaInitialized()
299	if gdk.DisplayGetDefault() == nil {
300		t.Skip("GTK display not available (gdk.DisplayGetDefault returned nil)")
301	}
302	appID := AppID
303	gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
304	if gtkApp == nil {
305		t.Fatal("gtk application creation failed")
306	}
307	return gtkApp
308}
309
310func Test_testHasUsableGTKDisplay_PureProbe(t *testing.T) {
311	t.Setenv("GDK_BACKEND", "")
312
313	t.Run("no display env vars", func(t *testing.T) {
314		t.Setenv("DISPLAY", "")
315		t.Setenv("WAYLAND_DISPLAY", "")
316		if got := testHasUsableGTKDisplay(); got {
317			t.Errorf("testHasUsableGTKDisplay() = %v, want false", got)
318		}
319	})
320
321	t.Run("DISPLAY numeric without socket", func(t *testing.T) {
322		displayNum := testUnusedX11DisplayNumber(t)
323		t.Setenv("DISPLAY", ":"+displayNum)
324		t.Setenv("WAYLAND_DISPLAY", "")
325		if got := testHasUsableGTKDisplay(); got {
326			t.Errorf("testHasUsableGTKDisplay() = %v, want false", got)
327		}
328	})
329
330	t.Run("DISPLAY with screen without socket", func(t *testing.T) {
331		displayNum := testUnusedX11DisplayNumber(t)
332		t.Setenv("DISPLAY", ":"+displayNum+".0")
333		t.Setenv("WAYLAND_DISPLAY", "")
334		if got := testHasUsableGTKDisplay(); got {
335			t.Errorf("testHasUsableGTKDisplay() = %v, want false", got)
336		}
337	})
338
339	t.Run("DISPLAY colon only no number", func(t *testing.T) {
340		t.Setenv("DISPLAY", ":")
341		t.Setenv("WAYLAND_DISPLAY", "")
342		if got := testHasUsableGTKDisplay(); got {
343			t.Errorf("testHasUsableGTKDisplay() = %v, want false for empty display number", got)
344		}
345	})
346
347	t.Run("DISPLAY TCP always passes socket check", func(t *testing.T) {
348		t.Setenv("DISPLAY", "remote-host:0")
349		t.Setenv("WAYLAND_DISPLAY", "")
350		if got := testHasUsableGTKDisplay(); !got {
351			t.Errorf("testHasUsableGTKDisplay() = %v, want true for TCP display", got)
352		}
353	})
354
355	t.Run("DISPLAY with existing socket", func(t *testing.T) {
356		displayNum := testUnusedX11DisplayNumber(t)
357		tmpDir := t.TempDir()
358		t.Setenv("TMPDIR", tmpDir)
359		t.Setenv("DISPLAY", ":"+displayNum)
360		t.Setenv("WAYLAND_DISPLAY", "")
361		testCreateUnixSocket(t, filepath.Join(tmpDir, ".X11-unix", "X"+displayNum))
362		if got := testHasUsableGTKDisplay(); !got {
363			t.Errorf("testHasUsableGTKDisplay() = %v, want true with existing X11 socket", got)
364		}
365	})
366
367	t.Run("WAYLAND_DISPLAY with existing socket", func(t *testing.T) {
368		t.Setenv("DISPLAY", "")
369		t.Setenv("WAYLAND_DISPLAY", "wayland-1")
370		tmpDir := t.TempDir()
371		socketPath := filepath.Join(tmpDir, "wayland-1")
372		testCreateUnixSocket(t, socketPath)
373		// XDG_RUNTIME_DIR controls where Wayland sockets are looked up
374		t.Setenv("XDG_RUNTIME_DIR", tmpDir)
375		if got := testHasUsableGTKDisplay(); !got {
376			t.Errorf("testHasUsableGTKDisplay() = %v, want true with existing wayland socket", got)
377		}
378	})
379
380	t.Run("WAYLAND_DISPLAY without socket", func(t *testing.T) {
381		t.Setenv("DISPLAY", "")
382		t.Setenv("WAYLAND_DISPLAY", "wayland-nonexistent")
383		t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
384		if got := testHasUsableGTKDisplay(); got {
385			t.Errorf("testHasUsableGTKDisplay() = %v, want false without wayland socket", got)
386		}
387	})
388
389	t.Run("WAYLAND_DISPLAY absolute path with existing socket", func(t *testing.T) {
390		t.Setenv("DISPLAY", "")
391		socketPath := filepath.Join(t.TempDir(), "wayland.sock")
392		testCreateUnixSocket(t, socketPath)
393		t.Setenv("WAYLAND_DISPLAY", socketPath)
394		t.Setenv("XDG_RUNTIME_DIR", "/nonexistent") // Should be ignored for absolute paths
395		if got := testHasUsableGTKDisplay(); !got {
396			t.Errorf("testHasUsableGTKDisplay() = %v, want true with absolute wayland socket path", got)
397		}
398	})
399}
400
401func Test_testHasX11Auth_PureProbe(t *testing.T) {
402	t.Run("DISPLAY unset", func(t *testing.T) {
403		t.Setenv("DISPLAY", "")
404		t.Setenv("XAUTHORITY", "")
405		if got := testHasX11Auth(); !got {
406			t.Errorf("testHasX11Auth() = %v, want true when DISPLAY unset", got)
407		}
408	})
409
410	t.Run("DISPLAY TCP remote", func(t *testing.T) {
411		t.Setenv("DISPLAY", "remote-host:0")
412		t.Setenv("XAUTHORITY", "")
413		if got := testHasX11Auth(); !got {
414			t.Errorf("testHasX11Auth() = %v, want true for TCP display", got)
415		}
416	})
417
418	t.Run("DISPLAY local no auth file", func(t *testing.T) {
419		t.Setenv("DISPLAY", ":0")
420		t.Setenv("XAUTHORITY", "")
421		// Use a temp home dir with no .Xauthority
422		t.Setenv("HOME", t.TempDir())
423		if got := testHasX11Auth(); got {
424			t.Errorf("testHasX11Auth() = %v, want false without auth file", got)
425		}
426	})
427
428	t.Run("DISPLAY local XAUTHORITY env missing file", func(t *testing.T) {
429		t.Setenv("DISPLAY", ":0")
430		t.Setenv("XAUTHORITY", "/nonexistent/.Xauthority")
431		if got := testHasX11Auth(); got {
432			t.Errorf("testHasX11Auth() = %v, want false when XAUTHORITY file missing", got)
433		}
434	})
435
436	t.Run("DISPLAY local XAUTHORITY with matching entry", func(t *testing.T) {
437		t.Setenv("DISPLAY", ":1")
438		authFile := filepath.Join(t.TempDir(), ".Xauthority")
439		createXauthFile(t, authFile, "1", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10})
440		t.Setenv("XAUTHORITY", authFile)
441		if got := testHasX11Auth(); !got {
442			t.Errorf("testHasX11Auth() = %v, want true with matching auth entry", got)
443		}
444	})
445
446	t.Run("DISPLAY local XAUTHORITY with non-matching entry", func(t *testing.T) {
447		t.Setenv("DISPLAY", ":2")
448		authFile := filepath.Join(t.TempDir(), ".Xauthority")
449		createXauthFile(t, authFile, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10})
450		t.Setenv("XAUTHORITY", authFile)
451		if got := testHasX11Auth(); got {
452			t.Errorf("testHasX11Auth() = %v, want false with non-matching auth entry", got)
453		}
454	})
455
456	t.Run("DISPLAY local with screen number", func(t *testing.T) {
457		t.Setenv("DISPLAY", ":0.0")
458		authFile := filepath.Join(t.TempDir(), ".Xauthority")
459		createXauthFile(t, authFile, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10})
460		t.Setenv("XAUTHORITY", authFile)
461		if got := testHasX11Auth(); !got {
462			t.Errorf("testHasX11Auth() = %v, want true with screen suffix stripped", got)
463		}
464	})
465
466	t.Run("DISPLAY colon only no number defaults to false", func(t *testing.T) {
467		t.Setenv("DISPLAY", ":")
468		t.Setenv("XAUTHORITY", "")
469		if got := testHasX11Auth(); got {
470			t.Errorf("testHasX11Auth() = %v, want false for empty display number", got)
471		}
472	})
473}
474
475// writeXauthRecord appends one local-family .Xauthority record to buf with the given fields.
476// If name or data is empty, the length byte is written as zero but the field
477// data is omitted, producing a truncated record suitable for malformed tests.
478func writeXauthRecord(buf *bytes.Buffer, num, name string, data []byte) {
479	// family (2 bytes, big-endian)
480	familyBytes := make([]byte, 2)
481	binary.BigEndian.PutUint16(familyBytes, 256)
482	buf.Write(familyBytes)
483
484	// address
485	addrLen := make([]byte, 2)
486	addr := "host"
487	binary.BigEndian.PutUint16(addrLen, uint16(len(addr)))
488	buf.Write(addrLen)
489	buf.WriteString(addr)
490
491	// number
492	numLen := make([]byte, 2)
493	binary.BigEndian.PutUint16(numLen, uint16(len(num)))
494	buf.Write(numLen)
495	buf.WriteString(num)
496
497	// name
498	nameLen := make([]byte, 2)
499	binary.BigEndian.PutUint16(nameLen, uint16(len(name)))
500	buf.Write(nameLen)
501	if name != "" {
502		buf.WriteString(name)
503	}
504
505	// data
506	dLen := make([]byte, 2)
507	binary.BigEndian.PutUint16(dLen, uint16(len(data)))
508	buf.Write(dLen)
509	if len(data) > 0 {
510		buf.Write(data)
511	}
512}
513
514func Test_testHasX11Auth_MalformedRecords(t *testing.T) {
515	t.Run("matching display with empty name field", func(t *testing.T) {
516		t.Setenv("DISPLAY", ":1")
517		authFile := filepath.Join(t.TempDir(), ".Xauthority")
518		var buf bytes.Buffer
519		writeXauthRecord(&buf, "1", "", []byte{0x01, 0x02, 0x03, 0x04})
520		if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
521			t.Fatal(err)
522		}
523		t.Setenv("XAUTHORITY", authFile)
524		if got := testHasX11Auth(); got {
525			t.Errorf("testHasX11Auth() = %v, want false for matching display with empty name", got)
526		}
527	})
528
529	t.Run("matching display with empty data field", func(t *testing.T) {
530		t.Setenv("DISPLAY", ":1")
531		authFile := filepath.Join(t.TempDir(), ".Xauthority")
532		var buf bytes.Buffer
533		writeXauthRecord(&buf, "1", "MIT-MAGIC-COOKIE-1", nil)
534		if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
535			t.Fatal(err)
536		}
537		t.Setenv("XAUTHORITY", authFile)
538		if got := testHasX11Auth(); got {
539			t.Errorf("testHasX11Auth() = %v, want false for matching display with empty data", got)
540		}
541	})
542
543	t.Run("truncated record after display number", func(t *testing.T) {
544		t.Setenv("DISPLAY", ":1")
545		authFile := filepath.Join(t.TempDir(), ".Xauthority")
546		var buf bytes.Buffer
547		family := make([]byte, 2)
548		binary.BigEndian.PutUint16(family, 256)
549		buf.Write(family)
550		addr := "host"
551		addrLen := make([]byte, 2)
552		binary.BigEndian.PutUint16(addrLen, uint16(len(addr)))
553		buf.Write(addrLen)
554		buf.WriteString(addr)
555		num := "1"
556		numLen := make([]byte, 2)
557		binary.BigEndian.PutUint16(numLen, uint16(len(num)))
558		buf.Write(numLen)
559		buf.WriteString(num)
560		// Record ends here — no name or data fields follow.
561		if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
562			t.Fatal(err)
563		}
564		t.Setenv("XAUTHORITY", authFile)
565		if got := testHasX11Auth(); got {
566			t.Errorf("testHasX11Auth() = %v, want false for truncated record", got)
567		}
568	})
569
570	t.Run("multiple entries with one matching and valid", func(t *testing.T) {
571		t.Setenv("DISPLAY", ":3")
572		authFile := filepath.Join(t.TempDir(), ".Xauthority")
573		var buf bytes.Buffer
574		writeXauthRecord(&buf, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04})
575		writeXauthRecord(&buf, "1", "MIT-MAGIC-COOKIE-1", []byte{0x05, 0x06, 0x07, 0x08})
576		writeXauthRecord(&buf, "3", "MIT-MAGIC-COOKIE-1", []byte{0x09, 0x0a, 0x0b, 0x0c})
577		if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
578			t.Fatal(err)
579		}
580		t.Setenv("XAUTHORITY", authFile)
581		if got := testHasX11Auth(); !got {
582			t.Errorf("testHasX11Auth() = %v, want true for third entry matching display :3", got)
583		}
584	})
585
586	t.Run("invalid matching entry does not hide later valid match", func(t *testing.T) {
587		t.Setenv("DISPLAY", ":3")
588		authFile := filepath.Join(t.TempDir(), ".Xauthority")
589		var buf bytes.Buffer
590		writeXauthRecord(&buf, "3", "", []byte{0x01, 0x02, 0x03, 0x04})
591		writeXauthRecord(&buf, "3", "MIT-MAGIC-COOKIE-1", []byte{0x09, 0x0a, 0x0b, 0x0c})
592		if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil {
593			t.Fatal(err)
594		}
595		t.Setenv("XAUTHORITY", authFile)
596		if got := testHasX11Auth(); !got {
597			t.Errorf("testHasX11Auth() = %v, want true for later valid matching display", got)
598		}
599	})
600}
601
602func Test_testHasUsableWaylandDisplay_PureProbe(t *testing.T) {
603	t.Setenv("GDK_BACKEND", "")
604
605	t.Run("no WAYLAND_DISPLAY", func(t *testing.T) {
606		t.Setenv("WAYLAND_DISPLAY", "")
607		if got := testHasUsableWaylandDisplay(); got {
608			t.Errorf("testHasUsableWaylandDisplay() = %v, want false", got)
609		}
610	})
611
612	t.Run("WAYLAND_DISPLAY with existing socket", func(t *testing.T) {
613		t.Setenv("WAYLAND_DISPLAY", "wayland-1")
614		tmpDir := t.TempDir()
615		socketPath := filepath.Join(tmpDir, "wayland-1")
616		testCreateUnixSocket(t, socketPath)
617		t.Setenv("XDG_RUNTIME_DIR", tmpDir)
618		if got := testHasUsableWaylandDisplay(); !got {
619			t.Errorf("testHasUsableWaylandDisplay() = %v, want true with existing wayland socket", got)
620		}
621	})
622
623	t.Run("WAYLAND_DISPLAY absolute path", func(t *testing.T) {
624		socketPath := filepath.Join(t.TempDir(), "wayland.sock")
625		testCreateUnixSocket(t, socketPath)
626		t.Setenv("WAYLAND_DISPLAY", socketPath)
627		t.Setenv("XDG_RUNTIME_DIR", "/nonexistent")
628		if got := testHasUsableWaylandDisplay(); !got {
629			t.Errorf("testHasUsableWaylandDisplay() = %v, want true with absolute path", got)
630		}
631	})
632}
633
634func Test_testHasUsableX11Display_PureProbe(t *testing.T) {
635	t.Setenv("GDK_BACKEND", "")
636
637	t.Run("no DISPLAY", func(t *testing.T) {
638		t.Setenv("DISPLAY", "")
639		if got := testHasUsableX11Display(); got {
640			t.Errorf("testHasUsableX11Display() = %v, want false", got)
641		}
642	})
643
644	t.Run("DISPLAY numeric without socket", func(t *testing.T) {
645		displayNum := testUnusedX11DisplayNumber(t)
646		t.Setenv("DISPLAY", ":"+displayNum)
647		if got := testHasUsableX11Display(); got {
648			t.Errorf("testHasUsableX11Display() = %v, want false", got)
649		}
650	})
651
652	t.Run("DISPLAY TCP always passes socket check", func(t *testing.T) {
653		t.Setenv("DISPLAY", "remote-host:0")
654		if got := testHasUsableX11Display(); !got {
655			t.Errorf("testHasUsableX11Display() = %v, want true for TCP display", got)
656		}
657	})
658}
659
660func Test_testHasUsableGTKDisplay_GDKBackendSelection(t *testing.T) {
661	t.Run("GDK_BACKEND=x11 ignores Wayland-only socket", func(t *testing.T) {
662		t.Setenv("GDK_BACKEND", "x11")
663		t.Setenv("WAYLAND_DISPLAY", "wayland-1")
664		tmpDir := t.TempDir()
665		testCreateUnixSocket(t, filepath.Join(tmpDir, "wayland-1"))
666		t.Setenv("XDG_RUNTIME_DIR", tmpDir)
667		t.Setenv("DISPLAY", ":"+testUnusedX11DisplayNumber(t))
668		if got := testHasUsableGTKDisplay(); got {
669			t.Errorf("testHasUsableGTKDisplay() = %v, want false when x11 is forced without an X11 socket", got)
670		}
671	})
672
673	t.Run("GDK_BACKEND=wayland ignores X11-only socket", func(t *testing.T) {
674		displayNum := testUnusedX11DisplayNumber(t)
675		t.Setenv("GDK_BACKEND", "wayland")
676		t.Setenv("DISPLAY", ":"+displayNum)
677		t.Setenv("WAYLAND_DISPLAY", "wayland-missing")
678		t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
679		testCreateX11Socket(t, displayNum)
680		if got := testHasUsableGTKDisplay(); got {
681			t.Errorf("testHasUsableGTKDisplay() = %v, want false when wayland is forced without a Wayland socket", got)
682		}
683	})
684
685	t.Run("GDK_BACKEND=wayland,x11 allows X11 fallback", func(t *testing.T) {
686		displayNum := testUnusedX11DisplayNumber(t)
687		t.Setenv("GDK_BACKEND", "wayland,x11")
688		t.Setenv("DISPLAY", ":"+displayNum)
689		t.Setenv("WAYLAND_DISPLAY", "wayland-missing")
690		t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
691		testCreateX11Socket(t, displayNum)
692		if got := testHasUsableGTKDisplay(); !got {
693			t.Errorf("testHasUsableGTKDisplay() = %v, want true with X11 fallback socket", got)
694		}
695		if got := testNeedsX11Auth(); !got {
696			t.Errorf("testNeedsX11Auth() = %v, want true when X11 is the only usable backend", got)
697		}
698	})
699}
700
701func Test_testNeedsX11Auth_PureProbe(t *testing.T) {
702	t.Run("Wayland available forces no X11 auth", func(t *testing.T) {
703		t.Setenv("GDK_BACKEND", "")
704		t.Setenv("WAYLAND_DISPLAY", "wayland-1")
705		tmpDir := t.TempDir()
706		socketPath := filepath.Join(tmpDir, "wayland-1")
707		testCreateUnixSocket(t, socketPath)
708		t.Setenv("XDG_RUNTIME_DIR", tmpDir)
709		t.Setenv("DISPLAY", ":0")
710		if got := testNeedsX11Auth(); got {
711			t.Errorf("testNeedsX11Auth() = %v, want false when Wayland is available and GDK_BACKEND is unset", got)
712		}
713	})
714
715	t.Run("GDK_BACKEND=x11 forces X11 auth even with Wayland", func(t *testing.T) {
716		t.Setenv("GDK_BACKEND", "x11")
717		t.Setenv("WAYLAND_DISPLAY", "wayland-1")
718		tmpDir := t.TempDir()
719		socketPath := filepath.Join(tmpDir, "wayland-1")
720		testCreateUnixSocket(t, socketPath)
721		t.Setenv("XDG_RUNTIME_DIR", tmpDir)
722		if got := testNeedsX11Auth(); !got {
723			t.Errorf("testNeedsX11Auth() = %v, want true when GDK_BACKEND=x11", got)
724		}
725	})
726
727	t.Run("No Wayland fallback needs X11 auth", func(t *testing.T) {
728		t.Setenv("GDK_BACKEND", "")
729		t.Setenv("WAYLAND_DISPLAY", "")
730		t.Setenv("DISPLAY", ":0")
731		if got := testNeedsX11Auth(); !got {
732			t.Errorf("testNeedsX11Auth() = %v, want true when only X11 is available", got)
733		}
734	})
735}
736
737// createXauthFile writes a minimal .Xauthority file with one entry.
738// family=256 (FamilyLocal), address=hostname, number=displayNum, name=proto, data=cookie.
739func createXauthFile(t *testing.T, path, displayNum, proto string, cookie []byte) {
740	t.Helper()
741
742	hostname, err := os.Hostname()
743	if err != nil {
744		hostname = "localhost"
745	}
746
747	var buf bytes.Buffer
748	// family (2 bytes, big-endian) - 256 = FamilyLocal
749	family := make([]byte, 2)
750	binary.BigEndian.PutUint16(family, 256)
751	buf.Write(family)
752
753	// address length + address (hostname)
754	addrLen := make([]byte, 2)
755	binary.BigEndian.PutUint16(addrLen, uint16(len(hostname)))
756	buf.Write(addrLen)
757	buf.WriteString(hostname)
758
759	// number length + number (display number)
760	numLen := make([]byte, 2)
761	binary.BigEndian.PutUint16(numLen, uint16(len(displayNum)))
762	buf.Write(numLen)
763	buf.WriteString(displayNum)
764
765	// name length + name (auth protocol)
766	nameLen := make([]byte, 2)
767	binary.BigEndian.PutUint16(nameLen, uint16(len(proto)))
768	buf.Write(nameLen)
769	buf.WriteString(proto)
770
771	// data length + data (auth cookie)
772	dataLen := make([]byte, 2)
773	binary.BigEndian.PutUint16(dataLen, uint16(len(cookie)))
774	buf.Write(dataLen)
775	buf.Write(cookie)
776
777	if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
778		t.Fatal(err)
779	}
780}
781
782// setDependencyField uses reflection to reach unexported dependency fields for tests.
783// Keep this test-only so production visibility stays narrow.
784func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) {
785	t.Helper()
786
787	rv := reflect.ValueOf(deps).Elem()
788	fv := rv.FieldByName(field)
789	if !fv.IsValid() {
790		t.Fatalf("Dependencies missing %s field", field)
791	}
792	fv.Set(reflect.ValueOf(value))
793}
794
795// windowForTabCount uses reflection to inspect unexported app state in tests.
796// Keep this test-only so production visibility stays narrow.
797func windowForTabCount(t *testing.T, app *App) int {
798	t.Helper()
799
800	rv := reflect.ValueOf(app).Elem()
801	fv := rv.FieldByName("windowForTab")
802	if !fv.IsValid() {
803		t.Fatalf("App missing windowForTab field")
804	}
805	return fv.Len()
806}
807
808func assertWindowOwnershipInvariant(t *testing.T, app *App) {
809	t.Helper()
810
811	seen := make(map[entity.TabID]string)
812	for windowID, bw := range app.browserWindows {
813		if bw == nil {
814			continue
815		}
816		if bw.tabs == nil {
817			t.Errorf("browser window %s has nil tabs", windowID)
818			continue
819		}
820		for _, tab := range bw.tabs.Tabs {
821			if tab == nil {
822				continue
823			}
824			if previous, exists := seen[tab.ID]; exists {
825				t.Errorf("tab %s is owned by both %s and %s", tab.ID, previous, windowID)
826				continue
827			}
828			seen[tab.ID] = windowID
829			if got := app.windowForTab[tab.ID]; got != bw {
830				t.Errorf("windowForTab[%s] = %p, want owner window %s (%p)", tab.ID, got, windowID, bw)
831			}
832		}
833	}
834	for tabID, bw := range app.windowForTab {
835		if bw == nil || bw.tabs == nil || bw.tabs.Find(tabID) == nil {
836			t.Errorf("windowForTab[%s] points to stale or non-owning window %p", tabID, bw)
837		}
838	}
839}
840
841// tabCoordinatorMainWindowPtr uses reflection to inspect unexported coordinator state in tests.
842// Keep this test-only so production visibility stays narrow.
843func tabCoordinatorMainWindowPtr(t *testing.T, tc *coordinator.TabCoordinator) uintptr {
844	t.Helper()
845
846	rv := reflect.ValueOf(tc).Elem()
847	fv := rv.FieldByName("mainWindow")
848	if !fv.IsValid() {
849		t.Fatalf("TabCoordinator missing mainWindow field")
850	}
851	return fv.Pointer()
852}
853
854func setWindowTabBar(t *testing.T, mw *window.MainWindow, tabBar *component.TabBar) {
855	t.Helper()
856
857	rv := reflect.ValueOf(mw).Elem()
858	fv := rv.FieldByName("tabBar")
859	if !fv.IsValid() {
860		t.Fatalf("MainWindow missing tabBar field")
861	}
862	reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Set(reflect.ValueOf(tabBar))
863}
864
865func omniboxNavigateCallbackForTest(t *testing.T, omnibox *component.Omnibox) func(context.Context, string) error {
866	t.Helper()
867	if omnibox == nil {
868		t.Fatal("omnibox is nil")
869	}
870
871	rv := reflect.ValueOf(omnibox).Elem()
872	fv := rv.FieldByName("onNavigate")
873	if !fv.IsValid() {
874		t.Fatal("Omnibox missing onNavigate field")
875	}
876	cb, ok := reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Interface().(func(context.Context, string) error)
877	if !ok || cb == nil {
878		t.Fatal("omnibox onNavigate callback is nil or has unexpected type")
879	}
880	return cb
881}
882
883func newTestTabBarShell(t *testing.T, tabIDs ...entity.TabID) *component.TabBar {
884	t.Helper()
885
886	tabBar := &component.TabBar{}
887	buttons := make(map[entity.TabID]*component.TabButton, len(tabIDs))
888	for _, tabID := range tabIDs {
889		buttons[tabID] = &component.TabButton{}
890	}
891
892	rv := reflect.ValueOf(tabBar).Elem()
893	fv := rv.FieldByName("buttons")
894	if !fv.IsValid() {
895		t.Fatalf("TabBar missing buttons field")
896	}
897	reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Set(reflect.ValueOf(buttons))
898
899	return tabBar
900}
901
902func windowTabBarActiveID(t *testing.T, mw *window.MainWindow) entity.TabID {
903	t.Helper()
904	if mw == nil || mw.TabBar() == nil {
905		return ""
906	}
907	return mw.TabBar().ActiveTabID()
908}
909
910func windowTitle(t *testing.T, mw *window.MainWindow) string {
911	t.Helper()
912	if mw == nil || mw.Window() == nil {
913		return ""
914	}
915	return mw.Window().GetTitle()
916}
917
918func windowTabBarVisible(t *testing.T, mw *window.MainWindow) bool {
919	t.Helper()
920	if mw == nil || mw.TabBar() == nil || mw.TabBar().Box() == nil {
921		return false
922	}
923	return mw.TabBar().Box().GetVisible()
924}
925
926func assertWindowTabBarAutoHidden(t *testing.T, mw *window.MainWindow, wantHidden bool) {
927	t.Helper()
928	if mw == nil || mw.TabBar() == nil || mw.TabBar().Box() == nil {
929		t.Fatal("missing tab bar")
930	}
931	box := mw.TabBar().Box()
932	if got := box.GetVisible(); !got {
933		t.Fatalf("tab bar visible = %v, want true so allocation is preserved", got)
934	}
935	if wantHidden {
936		if got := box.GetOpacity(); got != 0.0 {
937			t.Fatalf("tab bar opacity = %v, want 0", got)
938		}
939		if got := box.GetCanTarget(); got {
940			t.Fatalf("tab bar can target = %v, want false while auto-hidden", got)
941		}
942		return
943	}
944	if got := box.GetOpacity(); got != 1.0 {
945		t.Fatalf("tab bar opacity = %v, want 1", got)
946	}
947	if got := box.GetCanTarget(); !got {
948		t.Fatalf("tab bar can target = %v, want true while shown", got)
949	}
950}
951
952func stackedViewOnActivateIsNil(t *testing.T, sv *layout.StackedView) bool {
953	t.Helper()
954	if sv == nil {
955		return true
956	}
957
958	rv := reflect.ValueOf(sv).Elem()
959	fv := rv.FieldByName("onActivate")
960	if !fv.IsValid() {
961		t.Fatalf("StackedView missing onActivate field")
962	}
963	return reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().IsNil()
964}
965
966// newTestShellToaster builds a Toaster backed by testify mocks.
967// Callers that Show() must Hide() (or otherwise cancel the dismiss timer)
968// before the test returns — default Show arms a GLib TimeoutAdd that can
969// fire during a later test's MainContext.Iteration and panic on dead mocks.
970func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBoxWidget, *layoutmocks.MockLabelWidget) {
971	t.Helper()
972
973	factory := layoutmocks.NewMockWidgetFactory(t)
974	box := layoutmocks.NewMockBoxWidget(t)
975	label := layoutmocks.NewMockLabelWidget(t)
976
977	factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(box).Once()
978	factory.EXPECT().NewLabel("").Return(label).Once()
979	box.EXPECT().AddCssClass("toast").Once()
980	box.EXPECT().AddCssClass("toast-info").Once()
981	box.EXPECT().SetHalign(gtk.AlignStartValue).Twice()
982	box.EXPECT().SetValign(gtk.AlignStartValue).Twice()
983	box.EXPECT().SetHexpand(false).Once()
984	box.EXPECT().SetVexpand(false).Once()
985	box.EXPECT().SetCanTarget(false).Once()
986	box.EXPECT().SetCanFocus(false).Once()
987	box.EXPECT().SetVisible(false).Once()
988	box.EXPECT().Append(label).Once()
989	label.EXPECT().SetCanTarget(false).Once()
990	label.EXPECT().SetCanFocus(false).Once()
991
992	toaster := component.NewToaster(factory)
993	t.Cleanup(toaster.CancelAutoDismiss)
994	return toaster, box, label
995}
996
997func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
998	ctx := context.Background()
999	toaster, box, label := newTestShellToaster(t)
1000	box.EXPECT().SetVisible(true).Once()
1001	label.EXPECT().SetText("Ad blocker loading").Once()
1002	// Cancel the auto-dismiss GLib timer before mock teardown so a later test
1003	// iterating the main context cannot fire hide() on a completed test's mocks.
1004	box.EXPECT().SetVisible(false).Once()
1005
1006	bw := &browserWindow{id: "window-1", appToaster: toaster}
1007	app := &App{
1008		browserWindows:      map[string]*browserWindow{bw.id: bw},
1009		lastFocusedWindowID: bw.id,
1010	}
1011
1012	app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"})
1013	toaster.Hide()
1014}
1015
1016func TestTestShellToasterCleanupCancelsAutoDismiss(t *testing.T) {
1017	toaster, box, label := newTestShellToaster(t)
1018	box.EXPECT().SetVisible(true).Once()
1019	label.EXPECT().SetText("will not outlive this test").Once()
1020	toaster.Show(
1021		context.Background(),
1022		"will not outlive this test",
1023		component.ToastInfo,
1024		component.WithDuration(component.ToastBriefDurationMs),
1025	)
1026	toaster.CancelAutoDismiss()
1027
1028	mainContext := glib.MainContextDefault()
1029	deadline := time.Now().Add(component.ToastBriefDurationMs*time.Millisecond + 600*time.Millisecond)
1030	for time.Now().Before(deadline) {
1031		for mainContext.Pending() {
1032			mainContext.Iteration(false)
1033		}
1034		time.Sleep(10 * time.Millisecond)
1035	}
1036	assert.True(t, toaster.IsVisible(), "canceling must prevent the deferred hide callback")
1037}
1038
1039func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) {
1040	ctx := context.Background()
1041	toaster, box, label := newTestShellToaster(t)
1042	box.EXPECT().SetVisible(true).Once()
1043	label.EXPECT().SetText("Config has 1 new settings. Run 'dumber config migrate'").Once()
1044	// Cancel the auto-dismiss GLib timer before mock teardown so a later test
1045	// iterating the main context cannot fire hide() on a completed test's mocks.
1046	box.EXPECT().SetVisible(false).Once()
1047
1048	migrator := portmocks.NewMockConfigMigrator(t)
1049	migrator.EXPECT().CheckMigration().Return(&port.MigrationResult{MissingKeys: []string{"update.notify_on_new_settings"}}, nil).Once()
1050
1051	bw := &browserWindow{id: "window-1", appToaster: toaster}
1052	cfg := &config.Config{
1053		Update: config.UpdateConfig{NotifyOnNewSettings: true},
1054	}
1055	app := &App{
1056		deps: &Dependencies{
1057			MigrationChecker: migrator,
1058		},
1059		runtimeConfig:       runtimeConfigStateForTest(cfg),
1060		browserWindows:      map[string]*browserWindow{bw.id: bw},
1061		lastFocusedWindowID: bw.id,
1062	}
1063
1064	app.checkConfigMigration(ctx)
1065	toaster.Hide()
1066}
1067
1068func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) {
1069	relay := &testBrowserLaunchRelay{closer: &testCloser{}}
1070	deps := &Dependencies{}
1071	setDependencyField(t, deps, "BrowserLaunchRelay", relay)
1072
1073	app := &App{deps: deps, cancel: func(error) {}}
1074
1075	app.finalizeActivation(context.Background())
1076	app.finalizeActivation(context.Background())
1077
1078	if relay.listenCalls != 1 {
1079		t.Fatalf("Listen calls = %d, want 1", relay.listenCalls)
1080	}
1081
1082	app.onShutdown(context.Background())
1083
1084	if !relay.closer.closed {
1085		t.Fatalf("relay listener closer was not closed")
1086	}
1087}
1088
1089func TestApp_GetWindowSnapshotStateReturnsUnavailableWhenMainThreadDispatchTimesOut(t *testing.T) {
1090	app := &App{
1091		tabs:           entity.NewTabList(),
1092		browserWindows: map[string]*browserWindow{},
1093		dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
1094			if label != "ui.snapshot_window_state" {
1095				t.Fatalf("dispatch label = %q, want ui.snapshot_window_state", label)
1096			}
1097			return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchTimedOut, Elapsed: 5 * time.Millisecond}
1098		},
1099	}
1100
1101	windows, activeWindowIndex := app.GetWindowSnapshotState()
1102
1103	require.Nil(t, windows)
1104	require.Equal(t, -1, activeWindowIndex)
1105}
1106
1107func TestApp_OpenExternalURLReportsMainThreadDispatchTimeout(t *testing.T) {
1108	var factoryCalls int
1109	app := &App{
1110		dispatchOnMainThread: func(label string, fn func()) syncdispatch.SyncDispatchResult {
1111			return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchTimedOut, Elapsed: 5 * time.Millisecond}
1112		},
1113		browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
1114			factoryCalls++
1115			return &browserWindow{id: "window-timeout", tabs: entity.NewTabList()}, nil
1116		},
1117	}
1118
1119	err := app.OpenExternalURL(context.Background(), "https://example.com")
1120
1121	if err == nil {
1122		t.Fatal("OpenExternalURL returned nil error, want dispatch timeout")
1123	}
1124	if !strings.Contains(err.Error(), "main thread dispatch did not complete") {
1125		t.Fatalf("OpenExternalURL error = %q, want dispatch timeout", err.Error())
1126	}
1127	if factoryCalls != 0 {
1128		t.Fatalf("browserWindowFactory calls = %d, want 0", factoryCalls)
1129	}
1130}
1131
1132func TestApp_OpenExternalURLRecordsTabOwnership(t *testing.T) {
1133	existingTab := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
1134	existingTabs := entity.NewTabList()
1135	existingTabs.Add(existingTab)
1136	existingTabs.SetActive(existingTab.ID)
1137	existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs}
1138	created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
1139	app := &App{
1140		tabs:           entity.NewTabList(),
1141		tabsUC:         usecase.NewManageTabsUseCase(func() string { return "id-1" }, nil),
1142		browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
1143		windowForTab:   map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
1144		workspaceViews: map[entity.TabID]*component.WorkspaceView{entity.TabID("id-1"): &component.WorkspaceView{}},
1145		browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
1146			return created, nil
1147		},
1148	}
1149
1150	if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
1151		t.Fatalf("OpenExternalURL returned error: %v", err)
1152	}
1153	if got := windowForTabCount(t, app); got != 2 {
1154		t.Fatalf("windowForTab length = %d, want 2 (existing + new tab)", got)
1155	}
1156	assertWindowOwnershipInvariant(t, app)
1157}
1158
1159func TestApp_RestoreSessionHandlesEmptyWindowSnapshotAsSuccess(t *testing.T) {
1160	ctx := context.Background()
1161	sessionID := entity.SessionID("session-empty-windows")
1162	staleTab := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-workspace"), entity.NewPane(entity.PaneID("stale-pane")))
1163	staleTabs := entity.NewTabList()
1164	staleTabs.Add(staleTab)
1165	staleWindow := &browserWindow{id: "window-stale", tabs: staleTabs}
1166	state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{}, 0, time.Unix(123, 0))
1167
1168	app := &App{
1169		deps: &Dependencies{
1170			SessionStateRepo: mockSessionStateRepoWithSnapshot(t, sessionID, state),
1171		},
1172		runtimeConfig:       runtimeConfigStateForTest(&config.Config{}),
1173		tabs:                entity.NewTabList(),
1174		browserWindows:      map[string]*browserWindow{staleWindow.id: staleWindow},
1175		lastFocusedWindowID: staleWindow.id,
1176		workspaceViews:      map[entity.TabID]*component.WorkspaceView{staleTab.ID: {}},
1177		windowForTab:        map[entity.TabID]*browserWindow{staleTab.ID: staleWindow},
1178	}
1179	app.tabs.Add(staleTab)
1180
1181	err := app.restoreSession(ctx, sessionID)
1182	if err != nil {
1183		t.Fatalf("restoreSession returned error for valid v2 empty window snapshot: %v", err)
1184	}
1185	// Stale state should be cleared and stale window should have an empty tab list.
1186	if _, ok := app.workspaceViews[staleTab.ID]; ok {
1187		t.Fatal("stale workspace view was not cleared")
1188	}
1189	if _, ok := app.windowForTab[staleTab.ID]; ok {
1190		t.Fatal("stale windowForTab entry was not cleared")
1191	}
1192	if got := app.tabs.Count(); got != 0 {
1193		t.Fatalf("app.tabs.Count() = %d, want 0 after empty restore", got)
1194	}
1195	if got := staleWindow.tabs.Count(); got != 0 {
1196		t.Fatalf("staleWindow.tabs.Count() = %d, want 0 after empty restore", got)
1197	}
1198}
1199
1200func TestApp_CreateInitialTabNoFallbackAfterEmptyWindowRestore(t *testing.T) {
1201	ctx := context.Background()
1202	sessionID := entity.SessionID("session-empty-windows-fallback")
1203	state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{}, 0, time.Unix(123, 0))
1204	tabsUC := usecase.NewManageTabsUseCase(func() string { return "fallback-tab" }, nil)
1205	windowTabs := entity.NewTabList()
1206	bw := &browserWindow{id: "window-1", tabs: windowTabs}
1207	app := &App{
1208		deps: &Dependencies{
1209			RestoreSessionID: string(sessionID),
1210			InitialURL:       "https://fallback.example",
1211			SessionStateRepo: mockSessionStateRepoWithSnapshot(t, sessionID, state),
1212		},
1213		runtimeConfig:       runtimeConfigStateForTest(&config.Config{}),
1214		tabs:                entity.NewTabList(),
1215		tabsUC:              tabsUC,
1216		tabCoord:            coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: tabsUC, Tabs: entity.NewTabList()}),
1217		browserWindows:      map[string]*browserWindow{bw.id: bw},
1218		lastFocusedWindowID: bw.id,
1219	}
1220
1221	app.createInitialTab(ctx)
1222
1223	// For valid v2 empty-window restore, no fallback tab should be created.
1224	if created := windowTabs.Find(entity.TabID("fallback-tab")); created != nil {
1225		t.Fatal("fallback tab was created despite valid v2 empty-window restore")
1226	}
1227	if got := app.tabs.Count(); got != 0 {
1228		t.Fatalf("app.tabs.Count() = %d, want 0 after empty restore", got)
1229	}
1230}
1231
1232func TestApp_RestoreSessionClearsStaleUIStateBeforeApplyingRestoredTabs(t *testing.T) {
1233	gtkApp := requireGTKDisplayApp(t)
1234	defer gtkApp.Unref()
1235
1236	mainWindow, err := window.New(context.Background(), gtkApp, "top")
1237	if err != nil {
1238		t.Fatalf("window creation failed: %v", err)
1239	}
1240	defer mainWindow.Destroy()
1241
1242	staleTab := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-workspace"), entity.NewPane(entity.PaneID("stale-pane")))
1243	staleWindow := &browserWindow{id: "window-stale", mainWindow: mainWindow}
1244	staleSessionKey := floatingSessionKey{tabID: staleTab.ID, sessionID: "profile-stale"}
1245	restoredSessionID := entity.SessionID("session-restore")
1246	restoredTabs := entity.NewTabList()
1247	restoredTabs.Add(entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-workspace"), entity.NewPane(entity.PaneID("restored-pane"))))
1248
1249	app := &App{
1250		deps: &Dependencies{
1251			SessionStateRepo: mockSessionStateRepoWithSnapshot(t, restoredSessionID, entity.SnapshotFromTabList(restoredSessionID, restoredTabs)),
1252		},
1253		runtimeConfig:  runtimeConfigStateForTest(&config.Config{}),
1254		mainWindow:     mainWindow,
1255		widgetFactory:  layout.NewGtkWidgetFactory(),
1256		tabs:           entity.NewTabList(),
1257		browserWindows: map[string]*browserWindow{staleWindow.id: staleWindow},
1258		workspaceViews: map[entity.TabID]*component.WorkspaceView{staleTab.ID: &component.WorkspaceView{}},
1259		windowForTab:   map[entity.TabID]*browserWindow{staleTab.ID: staleWindow},
1260		floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
1261			staleSessionKey: {},
1262		},
1263	}
1264	app.tabs.Add(staleTab)
1265	mainWindow.TabBar().AddTab(staleTab)
1266
1267	if err := app.restoreSession(context.Background(), restoredSessionID); err != nil {
1268		t.Fatalf("restoreSession returned error: %v", err)
1269	}
1270
1271	if got := app.tabs.Count(); got != 1 {
1272		t.Fatalf("tabs.Count() = %d, want 1", got)
1273	}
1274	if got := mainWindow.TabBar().Count(); got != 1 {
1275		t.Fatalf("tab bar count = %d, want 1", got)
1276	}
1277	if _, ok := app.workspaceViews[staleTab.ID]; ok {
1278		t.Fatalf("stale workspace view was not removed")
1279	}
1280	if _, ok := app.windowForTab[staleTab.ID]; ok {
1281		t.Fatalf("stale windowForTab entry was not removed")
1282	}
1283	if _, ok := app.floatingSessions[staleSessionKey]; ok {
1284		t.Fatalf("stale floating session was not removed")
1285	}
1286	assertWindowOwnershipInvariant(t, app)
1287}
1288
1289func TestApp_RestoreSessionWiresStackedPaneTitleBarCallbacks(t *testing.T) {
1290	gtkApp := requireGTKDisplayApp(t)
1291	defer gtkApp.Unref()
1292
1293	mainWindow, err := window.New(context.Background(), gtkApp, "top")
1294	if err != nil {
1295		t.Fatalf("window creation failed: %v", err)
1296	}
1297	defer mainWindow.Destroy()
1298
1299	stackedSessionID := entity.SessionID("session-stacked-restore")
1300	stackedTabs := entity.NewTabList()
1301	stackedTabs.Add(entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-workspace"), entity.NewPane(entity.PaneID("restored-pane-1"))))
1302	stackedTabs.Tabs[0].Workspace = &entity.Workspace{
1303		ID: entity.WorkspaceID("restored-workspace"),
1304		Root: &entity.PaneNode{
1305			ID:        "restored-stack-root",
1306			IsStacked: true,
1307			Children: []*entity.PaneNode{
1308				{ID: "restored-child-1", Pane: entity.NewPane(entity.PaneID("restored-pane-1"))},
1309				{ID: "restored-child-2", Pane: entity.NewPane(entity.PaneID("restored-pane-2"))},
1310			},
1311			ActiveStackIndex: 0,
1312		},
1313		ActivePaneID: entity.PaneID("restored-pane-1"),
1314	}
1315
1316	app := &App{
1317		deps: &Dependencies{
1318			SessionStateRepo: mockSessionStateRepoWithSnapshot(t, stackedSessionID, entity.SnapshotFromTabList(stackedSessionID, stackedTabs)),
1319		},
1320		runtimeConfig:  runtimeConfigStateForTest(&config.Config{}),
1321		mainWindow:     mainWindow,
1322		widgetFactory:  layout.NewGtkWidgetFactory(),
1323		contentCoord:   &contentcoord.Coordinator{},
1324		wsCoord:        coordinator.NewWorkspaceCoordinator(context.Background(), coordinator.WorkspaceCoordinatorConfig{ContentCoord: &contentcoord.Coordinator{}}),
1325		tabs:           entity.NewTabList(),
1326		browserWindows: map[string]*browserWindow{},
1327		workspaceViews: map[entity.TabID]*component.WorkspaceView{},
1328		windowForTab:   map[entity.TabID]*browserWindow{},
1329	}
1330
1331	if err := app.restoreSession(context.Background(), stackedSessionID); err != nil {
1332		t.Fatalf("restoreSession returned error: %v", err)
1333	}
1334
1335	restoredTab := app.tabs.ActiveTab()
1336	if restoredTab == nil {
1337		t.Fatal("restored active tab missing")
1338	}
1339	wv := app.workspaceViews[restoredTab.ID]
1340	if wv == nil {
1341		t.Fatal("restored workspace view missing")
1342	}
1343	tr := wv.TreeRenderer()
1344	if tr == nil {
1345		t.Fatal("restored tree renderer missing")
1346	}
1347	if restoredTab.Workspace == nil || restoredTab.Workspace.Root == nil || len(restoredTab.Workspace.Root.Children) == 0 {
1348		t.Fatal("restored stacked workspace missing children")
1349	}
1350	stackedView := tr.GetStackedViewForPane(string(restoredTab.Workspace.Root.Children[0].Pane.ID))
1351	if stackedView == nil {
1352		t.Fatal("restored stacked view missing")
1353	}
1354	if stackedViewOnActivateIsNil(t, stackedView) {
1355		t.Fatal("restored stacked view onActivate callback is nil")
1356	}
1357}
1358
1359func TestApp_PopupOwnerWindowIDForFloatingPaneUsesSourceTabWindow(t *testing.T) {
1360	tabID := entity.TabID("source-tab")
1361	owner := &browserWindow{id: "owner-window", tabs: entity.NewTabList()}
1362	paneID := entity.PaneID("floating-pane")
1363	app := &App{
1364		browserWindows: map[string]*browserWindow{owner.id: owner},
1365		windowForTab:   map[entity.TabID]*browserWindow{tabID: owner},
1366		floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
1367			{tabID: tabID, sessionID: "profile"}: {paneID: paneID},
1368		},
1369	}
1370
1371	windowID, ok := app.popupOwnerWindowIDForPane(paneID)
1372
1373	require.True(t, ok)
1374	require.Equal(t, owner.id, windowID)
1375}
1376
1377func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T) {
1378	oldWindow := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
1379	newWindow := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
1380	tc := coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
1381		MainWindow: oldWindow.mainWindow,
1382	})
1383	app := &App{
1384		mainWindow:     oldWindow.mainWindow,
1385		browserWindows: map[string]*browserWindow{oldWindow.id: oldWindow, newWindow.id: newWindow},
1386		tabCoord:       tc,
1387	}
1388
1389	app.removeBrowserWindow(oldWindow.id)
1390
1391	if app.mainWindow != newWindow.mainWindow {
1392		t.Fatalf("mainWindow = %p, want %p", app.mainWindow, newWindow.mainWindow)
1393	}
1394	if got := tabCoordinatorMainWindowPtr(t, tc); got != reflect.ValueOf(newWindow.mainWindow).Pointer() {
1395		t.Fatalf("tab coordinator mainWindow = %x, want %x", got, reflect.ValueOf(newWindow.mainWindow).Pointer())
1396	}
1397}
1398
1399func TestApp_OpenExternalURLRollsBackOnTabCreationFailure(t *testing.T) {
1400	created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
1401	originalWindow := &window.MainWindow{}
1402	tabBar := &component.TabBar{}
1403	setWindowTabBar(t, originalWindow, tabBar)
1404	existingTabID := entity.TabID("existing-tab")
1405	staleTabID := entity.TabID("stale-tab")
1406	tabBar.SetActive(staleTabID)
1407
1408	existingTab := entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
1409	existingTabs := entity.NewTabList()
1410	existingTabs.Add(existingTab)
1411	existingTabs.SetActive(existingTab.ID)
1412	existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs, mainWindow: originalWindow}
1413
1414	app := &App{
1415		tabs:           entity.NewTabList(),
1416		tabsUC:         usecase.NewManageTabsUseCase(func() string { return "id-1" }, nil),
1417		browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
1418		windowForTab:   map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
1419		browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
1420			return created, nil
1421		},
1422		mainWindow: originalWindow,
1423	}
1424	app.tabCoord = coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
1425		TabsUC:     app.tabsUC,
1426		Tabs:       entity.NewTabList(),
1427		MainWindow: &window.MainWindow{},
1428	})
1429
1430	if err := app.OpenExternalURL(context.Background(), "https://example.com/fail"); err == nil {
1431		t.Fatalf("OpenExternalURL = nil error, want failure")
1432	}
1433	if got := len(app.browserWindows); got != 1 {
1434		t.Fatalf("browserWindows length = %d, want 1 (existing window only)", got)
1435	}
1436	if app.browserWindows[existingWindow.id] == nil {
1437		t.Fatal("existing window should remain after rollback")
1438	}
1439	if app.browserWindows["window-1"] != nil {
1440		t.Fatal("created window should be removed after rollback")
1441	}
1442	if got := windowForTabCount(t, app); got != 1 {
1443		t.Fatalf("windowForTab length = %d, want 1 (existing tab only)", got)
1444	}
1445	if got := windowTabBarActiveID(t, originalWindow); got != staleTabID {
1446		t.Fatalf("tab bar active tab = %q, want %q (original window unchanged)", got, staleTabID)
1447	}
1448}
1449
1450func TestApp_OpenExternalURLTargetsNewWindowTabBar(t *testing.T) {
1451	existingTabID := entity.TabID("existing-tab")
1452	createdTabID := entity.TabID("id-1")
1453	oldWindow := &window.MainWindow{}
1454	newWindow := &window.MainWindow{}
1455	setWindowTabBar(t, oldWindow, newTestTabBarShell(t))
1456	setWindowTabBar(t, newWindow, newTestTabBarShell(t, createdTabID))
1457	tabs := entity.NewTabList()
1458	tabsUC := usecase.NewManageTabsUseCase(func() string { return string(createdTabID) }, nil)
1459
1460	existingTab := entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
1461	existingTabs := entity.NewTabList()
1462	existingTabs.Add(existingTab)
1463	existingTabs.SetActive(existingTab.ID)
1464	existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs, mainWindow: oldWindow}
1465
1466	app := &App{
1467		tabs:           tabs,
1468		tabsUC:         tabsUC,
1469		browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
1470		windowForTab:   map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
1471		browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
1472			return &browserWindow{id: "window-1", mainWindow: newWindow}, nil
1473		},
1474		mainWindow: oldWindow,
1475		tabCoord: coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
1476			TabsUC:                  tabsUC,
1477			Tabs:                    tabs,
1478			MainWindow:              oldWindow,
1479			HideTabBarWhenSingleTab: true,
1480		}),
1481		workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
1482	}
1483	app.tabCoord.SetOnTabCreated(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) {
1484		app.workspaceViews[tab.ID] = &component.WorkspaceView{}
1485	})
1486
1487	if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
1488		t.Fatalf("OpenExternalURL returned error: %v", err)
1489	}
1490
1491	gotCreatedTabID := app.tabs.ActiveTabID
1492	if gotCreatedTabID == "" {
1493		t.Fatalf("created tab id = %q, want non-empty", gotCreatedTabID)
1494	}
1495	if got := windowTabBarActiveID(t, newWindow); got != gotCreatedTabID {
1496		t.Fatalf("new window tab bar active tab = %q, want %q", got, gotCreatedTabID)
1497	}
1498	if got := windowTabBarActiveID(t, oldWindow); got != "" {
1499		t.Fatalf("old window tab bar active tab = %q, want empty", got)
1500	}
1501	// The lightweight test tab bar shell has no GTK box, so allocation state is
1502	// covered by TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAllocation.
1503}
1504
1505func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) {
1506	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1507	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1508	tabs := entity.NewTabList()
1509	tabs.Add(tab1)
1510	tabs.Add(tab2)
1511	ws1 := &component.WorkspaceView{}
1512	ws2 := &component.WorkspaceView{}
1513	firstTabs := entity.NewTabList()
1514	firstTabs.Add(tab1)
1515	firstTabs.SetActive(tab1.ID)
1516	first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
1517	secondTabs := entity.NewTabList()
1518	secondTabs.Add(tab2)
1519	secondTabs.SetActive(tab2.ID)
1520	second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
1521	kh := &input.KeyboardHandler{}
1522	gs := &input.GlobalShortcutHandler{}
1523	second.keyboardHandler = kh
1524	second.globalShortcutHandler = gs
1525	app := &App{
1526		tabs:           tabs,
1527		mainWindow:     first.mainWindow,
1528		browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1529		windowForTab:   map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
1530		workspaceViews: map[entity.TabID]*component.WorkspaceView{tab1.ID: ws1, tab2.ID: ws2},
1531	}
1532
1533	app.activateBrowserWindow(second)
1534
1535	if app.mainWindow != second.mainWindow {
1536		t.Fatalf("mainWindow = %p, want %p", app.mainWindow, second.mainWindow)
1537	}
1538	if second.tabs.ActiveTabID != tab2.ID {
1539		t.Fatalf("active tab = %q, want %q", second.tabs.ActiveTabID, tab2.ID)
1540	}
1541	if app.activeWorkspaceView() != ws2 {
1542		t.Fatalf("active workspace view = %p, want %p", app.activeWorkspaceView(), ws2)
1543	}
1544	if app.keyboardHandler != kh {
1545		t.Fatalf("keyboardHandler = %p, want %p", app.keyboardHandler, kh)
1546	}
1547	if app.globalShortcutHandler != gs {
1548		t.Fatalf("globalShortcutHandler = %p, want %p", app.globalShortcutHandler, gs)
1549	}
1550}
1551
1552func TestApp_BrowserWindowForPaneFindsOwningWindow(t *testing.T) {
1553	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1554	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1555	tabs := entity.NewTabList()
1556	tabs.Add(tab1)
1557	tabs.Add(tab2)
1558	firstTabs := entity.NewTabList()
1559	firstTabs.Add(tab1)
1560	secondTabs := entity.NewTabList()
1561	secondTabs.Add(tab2)
1562	first := &browserWindow{id: "window-1", tabs: firstTabs}
1563	second := &browserWindow{id: "window-2", tabs: secondTabs}
1564	app := &App{
1565		tabs:           tabs,
1566		browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1567		windowForTab:   map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
1568	}
1569
1570	got := app.browserWindowForPane(entity.PaneID("pane-2"))
1571
1572	if got != second {
1573		t.Fatalf("browserWindowForPane = %p, want %p", got, second)
1574	}
1575}
1576
1577func TestApp_OwnerOrLastFocusedBrowserWindowPrefersPaneOwner(t *testing.T) {
1578	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1579	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1580	tabs := entity.NewTabList()
1581	tabs.Add(tab1)
1582	tabs.Add(tab2)
1583	firstTabs := entity.NewTabList()
1584	firstTabs.Add(tab1)
1585	secondTabs := entity.NewTabList()
1586	secondTabs.Add(tab2)
1587	first := &browserWindow{id: "window-1", tabs: firstTabs}
1588	second := &browserWindow{id: "window-2", tabs: secondTabs}
1589	app := &App{
1590		tabs:                tabs,
1591		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1592		windowForTab:        map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
1593		lastFocusedWindowID: first.id,
1594	}
1595
1596	got := app.ownerOrLastFocusedBrowserWindow("", entity.PaneID("pane-2"))
1597
1598	if got != second {
1599		t.Fatalf("ownerOrLastFocusedBrowserWindow = %p, want %p", got, second)
1600	}
1601}
1602
1603func TestApp_HandlePaneWindowTitleChangedTargetsOwningWindow(t *testing.T) {
1604	gtkApp := requireGTKDisplayApp(t)
1605	defer gtkApp.Unref()
1606
1607	firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
1608	if err != nil {
1609		t.Fatalf("first window creation failed: %v", err)
1610	}
1611	defer firstMainWindow.Destroy()
1612	secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
1613	if err != nil {
1614		t.Fatalf("second window creation failed: %v", err)
1615	}
1616	defer secondMainWindow.Destroy()
1617
1618	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1619	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1620	tabs := entity.NewTabList()
1621	tabs.Add(tab1)
1622	tabs.Add(tab2)
1623	firstTabs := entity.NewTabList()
1624	firstTabs.Add(tab1)
1625	firstTabs.SetActive(tab1.ID)
1626	secondTabs := entity.NewTabList()
1627	secondTabs.Add(tab2)
1628	secondTabs.SetActive(tab2.ID)
1629	first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
1630	second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
1631	app := &App{
1632		tabs:                tabs,
1633		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1634		windowForTab:        map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
1635		lastFocusedWindowID: first.id,
1636	}
1637
1638	app.handlePaneWindowTitleChanged(entity.PaneID("pane-2"), "Pane Two")
1639
1640	if got := windowTitle(t, firstMainWindow); got != "Dumber" {
1641		t.Fatalf("first window title = %q, want %q", got, "Dumber")
1642	}
1643	if got := windowTitle(t, secondMainWindow); got != "Pane Two - Dumber" {
1644		t.Fatalf("second window title = %q, want %q", got, "Pane Two - Dumber")
1645	}
1646}
1647
1648func TestApp_ActivateBrowserWindowResyncsTitleFromBackgroundPane(t *testing.T) {
1649	gtkApp := requireGTKDisplayApp(t)
1650	defer gtkApp.Unref()
1651
1652	firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
1653	if err != nil {
1654		t.Fatalf("first window creation failed: %v", err)
1655	}
1656	defer firstMainWindow.Destroy()
1657	secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
1658	if err != nil {
1659		t.Fatalf("second window creation failed: %v", err)
1660	}
1661	defer secondMainWindow.Destroy()
1662
1663	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1664	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1665	tabs := entity.NewTabList()
1666	tabs.Add(tab1)
1667	tabs.Add(tab2)
1668	firstTabs := entity.NewTabList()
1669	firstTabs.Add(tab1)
1670	firstTabs.SetActive(tab1.ID)
1671	first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
1672	secondTabs := entity.NewTabList()
1673	secondTabs.Add(tab2)
1674	secondTabs.SetActive(tab2.ID)
1675	second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
1676	contentCoord := &contentcoord.Coordinator{}
1677	contentTitles := reflect.ValueOf(contentCoord).Elem().FieldByName("paneTitles")
1678	reflect.NewAt(contentTitles.Type(), unsafe.Pointer(contentTitles.UnsafeAddr())).Elem().Set(reflect.ValueOf(map[entity.PaneID]string{tab2.Workspace.ActivePaneID: "Pane Two"}))
1679	app := &App{
1680		tabs:           tabs,
1681		browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1682		windowForTab:   map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
1683		contentCoord:   contentCoord,
1684	}
1685
1686	app.handlePaneWindowTitleChanged(tab2.Workspace.ActivePaneID, "Pane Two")
1687	secondMainWindow.SetTitle("Dumber")
1688
1689	app.activateBrowserWindow(second)
1690
1691	if got := windowTitle(t, secondMainWindow); got != "Pane Two - Dumber" {
1692		t.Fatalf("second window title = %q, want %q", got, "Pane Two - Dumber")
1693	}
1694	if got := windowTitle(t, firstMainWindow); got != "Dumber" {
1695		t.Fatalf("first window title = %q, want %q", got, "Dumber")
1696	}
1697}
1698
1699func TestApp_HandlePaneFullscreenChangedTargetsOwningWindow(t *testing.T) {
1700	gtkApp := requireGTKDisplayApp(t)
1701	defer gtkApp.Unref()
1702
1703	firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
1704	if err != nil {
1705		t.Fatalf("first window creation failed: %v", err)
1706	}
1707	defer firstMainWindow.Destroy()
1708	secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
1709	if err != nil {
1710		t.Fatalf("second window creation failed: %v", err)
1711	}
1712	defer secondMainWindow.Destroy()
1713
1714	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1715	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1716	tabs := entity.NewTabList()
1717	tabs.Add(tab1)
1718	tabs.Add(tab2)
1719	secondMainWindow.TabBar().AddTab(tab1)
1720	secondMainWindow.TabBar().AddTab(tab2)
1721	firstTabs := entity.NewTabList()
1722	firstTabs.Add(tab1)
1723	firstTabs.SetActive(tab1.ID)
1724	secondTabs := entity.NewTabList()
1725	secondTabs.Add(tab2)
1726	secondTabs.SetActive(tab2.ID)
1727	first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
1728	second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
1729	app := &App{
1730		tabs:                tabs,
1731		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1732		windowForTab:        map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
1733		lastFocusedWindowID: first.id,
1734	}
1735
1736	app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), true)
1737
1738	if windowTabBarVisible(t, firstMainWindow) != true {
1739		t.Fatalf("first window tab bar visibility changed unexpectedly")
1740	}
1741	if got := windowTabBarVisible(t, secondMainWindow); got {
1742		t.Fatalf("second window tab bar visible = %v, want false", got)
1743	}
1744
1745	app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), false)
1746
1747	if got := windowTabBarVisible(t, secondMainWindow); !got {
1748		t.Fatalf("second window tab bar visible = %v, want true", got)
1749	}
1750}
1751
1752func TestApp_HandlePaneFullscreenChangedIgnoresUnresolvedPane(t *testing.T) {
1753	gtkApp := requireGTKDisplayApp(t)
1754	defer gtkApp.Unref()
1755
1756	firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
1757	if err != nil {
1758		t.Fatalf("first window creation failed: %v", err)
1759	}
1760	defer firstMainWindow.Destroy()
1761	secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
1762	if err != nil {
1763		t.Fatalf("second window creation failed: %v", err)
1764	}
1765	defer secondMainWindow.Destroy()
1766	firstMainWindow.TabBar().SetVisible(true)
1767	secondMainWindow.TabBar().SetVisible(true)
1768	first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}
1769	second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}
1770	app := &App{
1771		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1772		lastFocusedWindowID: first.id,
1773	}
1774
1775	app.handlePaneFullscreenChanged(entity.PaneID("missing-pane"), true)
1776
1777	if got := windowTabBarVisible(t, firstMainWindow); !got {
1778		t.Fatalf("first window tab bar visible = %v, want true", got)
1779	}
1780	if got := windowTabBarVisible(t, secondMainWindow); !got {
1781		t.Fatalf("second window tab bar visible = %v, want true", got)
1782	}
1783}
1784
1785func TestApp_HandleAccentKeyPressDelegatesToOwningWindow(t *testing.T) {
1786	first := newTestAccentUseCase(t, true)
1787	second := newTestAccentUseCase(t, false)
1788	firstWindow := &browserWindow{id: "window-1", insertAccentUC: first}
1789	secondWindow := &browserWindow{id: "window-2", insertAccentUC: second}
1790	app := &App{
1791		browserWindows:      map[string]*browserWindow{firstWindow.id: firstWindow, secondWindow.id: secondWindow},
1792		lastFocusedWindowID: secondWindow.id,
1793	}
1794
1795	if got := app.handleAccentKeyPress(context.Background(), uint('e'), 0); got {
1796		t.Fatalf("handleAccentKeyPress returned %v, want false from active shell handler", got)
1797	}
1798	if got := first.IsPickerVisible(); !got {
1799		t.Fatalf("first shell accent handler should remain visible")
1800	}
1801	if got := second.IsPickerVisible(); got {
1802		t.Fatalf("second shell accent handler should remain hidden")
1803	}
1804}
1805
1806func TestApp_MoveActivePaneToTabFromBrowserWindowAnchorsOwningWindow(t *testing.T) {
1807	tabs := entity.NewTabList()
1808	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1809	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1810	tabs.Add(tab1)
1811	tabs.Add(tab2)
1812	firstTabs := entity.NewTabList()
1813	firstTabs.Add(tab1)
1814	firstTabs.SetActive(tab1.ID)
1815	first := &browserWindow{id: "window-1", tabs: firstTabs}
1816	secondTabs := entity.NewTabList()
1817	secondTabs.Add(tab2)
1818	secondTabs.SetActive(tab2.ID)
1819	second := &browserWindow{id: "window-2", tabs: secondTabs}
1820	app := &App{
1821		tabs:                tabs,
1822		movePaneToTabUC:     usecase.NewMovePaneToTabUseCase(func() string { return "new-tab" }),
1823		deps:                &Dependencies{},
1824		runtimeConfig:       runtimeConfigStateForTest(&config.Config{}),
1825		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1826		lastFocusedWindowID: second.id,
1827	}
1828
1829	if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, ""); err != nil {
1830		t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err)
1831	}
1832
1833	if tabs.Find(tab1.ID) != nil {
1834		t.Fatalf("tab-1 should have been moved from owning window")
1835	}
1836	if tabs.Find(tab2.ID) == nil {
1837		t.Fatalf("tab-2 should remain when owning window is preserved")
1838	}
1839}
1840
1841func TestApp_MoveActivePaneToTabFromBrowserWindowActivatesTargetOwnerForCrossWindowTab(t *testing.T) {
1842	tabs := entity.NewTabList()
1843	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1844	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1845	tab3 := entity.NewTab(entity.TabID("tab-3"), entity.WorkspaceID("workspace-3"), entity.NewPane(entity.PaneID("pane-3")))
1846	tabs.Add(tab1)
1847	tabs.Add(tab2)
1848	tabs.Add(tab3)
1849	tabs.SetActive(tab1.ID)
1850	firstTabs := entity.NewTabList()
1851	firstTabs.Add(tab1)
1852	firstTabs.SetActive(tab1.ID)
1853	first := &browserWindow{id: "window-1", tabs: firstTabs}
1854	secondTabs := entity.NewTabList()
1855	secondTabs.Add(tab2)
1856	secondTabs.SetActive(tab2.ID)
1857	second := &browserWindow{id: "window-2", tabs: secondTabs}
1858	app := &App{
1859		tabs:                tabs,
1860		movePaneToTabUC:     usecase.NewMovePaneToTabUseCase(func() string { return "generated" }),
1861		deps:                &Dependencies{},
1862		runtimeConfig:       runtimeConfigStateForTest(&config.Config{}),
1863		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1864		windowForTab:        map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second, tab3.ID: second},
1865		lastFocusedWindowID: first.id,
1866	}
1867
1868	if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, tab3.ID); err != nil {
1869		t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err)
1870	}
1871
1872	if tabs.Find(tab1.ID) != nil {
1873		t.Fatalf("tab-1 should be removed after its active pane moves away")
1874	}
1875	if tabs.Find(tab2.ID) == nil {
1876		t.Fatalf("tab-2 should remain in the target owner window")
1877	}
1878	if tabs.Find(tab3.ID) == nil {
1879		t.Fatalf("tab-3 should remain as the move target")
1880	}
1881	if app.tabs.ActiveTabID != tab3.ID {
1882		t.Fatalf("active tab = %q, want %q", app.tabs.ActiveTabID, tab3.ID)
1883	}
1884	if app.lastFocusedWindowID != second.id {
1885		t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id)
1886	}
1887}
1888
1889func TestApp_MoveActivePaneToTabFromBrowserWindowCrossWindowDerivedMirror(t *testing.T) {
1890	// app.tabs initially contains ONLY source tab (not target tab) to simulate
1891	// a target created only in browserWindow.tabs (the real-world scenario that
1892	// CodeRabbit flagged). The fix ensures buildMovePaneToTabInput syncs the
1893	// derived global mirror before passing a.tabs to the move usecase.
1894	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1895	tab3 := entity.NewTab(entity.TabID("tab-3"), entity.WorkspaceID("workspace-3"), entity.NewPane(entity.PaneID("pane-3")))
1896
1897	// app.tabs contains only tab1 — tab3 is absent from the mirror.
1898	tabs := entity.NewTabList()
1899	tabs.Add(tab1)
1900	tabs.SetActive(tab1.ID)
1901
1902	firstTabs := entity.NewTabList()
1903	firstTabs.Add(tab1)
1904	firstTabs.SetActive(tab1.ID)
1905	first := &browserWindow{id: "window-1", tabs: firstTabs}
1906
1907	secondTabs := entity.NewTabList()
1908	secondTabs.Add(tab3)
1909	secondTabs.SetActive(tab3.ID)
1910	second := &browserWindow{id: "window-2", tabs: secondTabs}
1911
1912	initialPaneCount := tab3.Workspace.PaneCount()
1913
1914	app := &App{
1915		tabs:                tabs,
1916		movePaneToTabUC:     usecase.NewMovePaneToTabUseCase(func() string { return "generated" }),
1917		deps:                &Dependencies{},
1918		runtimeConfig:       runtimeConfigStateForTest(&config.Config{}),
1919		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1920		windowForTab:        map[entity.TabID]*browserWindow{tab1.ID: first, tab3.ID: second},
1921		lastFocusedWindowID: first.id,
1922	}
1923
1924	if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, tab3.ID); err != nil {
1925		t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err)
1926	}
1927
1928	// The derived mirror must now contain tab3 (synced by the fix).
1929	if app.tabs.Find(tab3.ID) == nil {
1930		t.Fatalf("tab-3 should have been synced into the derived global mirror")
1931	}
1932
1933	// No new tab should have been generated — the move should target tab3.
1934	if app.tabs.Find(entity.TabID("generated")) != nil {
1935		t.Fatalf("a new tab was incorrectly generated; the move should target tab-3")
1936	}
1937
1938	// The moved pane should be in tab3's workspace.
1939	if got := tab3.Workspace.PaneCount(); got != initialPaneCount+1 {
1940		t.Fatalf("tab-3 pane count = %d, want %d (original + moved pane)", got, initialPaneCount+1)
1941	}
1942
1943	// windowForTab for tab3 must remain second.
1944	if app.windowForTab[tab3.ID] != second {
1945		t.Fatalf("windowForTab[tab-3] = %p, want second window %p", app.windowForTab[tab3.ID], second)
1946	}
1947}
1948
1949func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T) {
1950	gtkApp := requireGTKDisplayApp(t)
1951	defer gtkApp.Unref()
1952
1953	mainWindow, err := window.New(context.Background(), gtkApp, "top")
1954	if err != nil {
1955		t.Fatalf("window creation failed: %v", err)
1956	}
1957	defer mainWindow.Destroy()
1958
1959	sentinelKeyboardHandler := &input.KeyboardHandler{}
1960	sentinelShortcutHandler := &input.GlobalShortcutHandler{}
1961	bw := &browserWindow{
1962		id:                    "window-1",
1963		mainWindow:            mainWindow,
1964		keyboardHandler:       sentinelKeyboardHandler,
1965		globalShortcutHandler: sentinelShortcutHandler,
1966	}
1967	app := &App{
1968		deps:           &Dependencies{},
1969		runtimeConfig:  runtimeConfigStateForTest(&config.Config{}),
1970		mainWindow:     mainWindow,
1971		browserWindows: map[string]*browserWindow{bw.id: bw},
1972		kbDispatcher:   dispatcher.NewKeyboardDispatcher(context.Background(), nil, nil, nil, nil, dispatcher.KeyboardActions{}, func(context.Context) entity.PaneID { return "" }),
1973	}
1974
1975	app.initKeyboardHandler(context.Background())
1976
1977	if bw.keyboardHandler != sentinelKeyboardHandler {
1978		t.Fatalf("keyboardHandler was reattached")
1979	}
1980	if bw.globalShortcutHandler != sentinelShortcutHandler {
1981		t.Fatalf("globalShortcutHandler was reattached")
1982	}
1983}
1984
1985func newTestAccentUseCase(t *testing.T, pickerVisible bool) *usecase.InsertAccentUseCase {
1986	t.Helper()
1987	uc := usecase.NewInsertAccentUseCase(&testFocusedInputProvider{}, nil, nil)
1988	if uc == nil {
1989		t.Fatal("failed to create accent use case")
1990	}
1991	setAccentUseCaseBoolField(t, uc, "pickerVisible", pickerVisible)
1992	return uc
1993}
1994
1995func setAccentUseCaseBoolField(t *testing.T, uc *usecase.InsertAccentUseCase, field string, value bool) {
1996	t.Helper()
1997	rv := reflect.ValueOf(uc).Elem()
1998	fv := rv.FieldByName(field)
1999	if !fv.IsValid() {
2000		t.Fatalf("InsertAccentUseCase missing %s field", field)
2001	}
2002	reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().SetBool(value)
2003}
2004
2005type testFocusedInputProvider struct{}
2006
2007func (*testFocusedInputProvider) GetFocusedInput() port.TextInputTarget { return nil }
2008func (*testFocusedInputProvider) SetFocusedInput(port.TextInputTarget)  {}
2009
2010func TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAllocation(t *testing.T) {
2011	gtkApp := requireGTKDisplayApp(t)
2012	defer gtkApp.Unref()
2013
2014	mainWindow := &window.MainWindow{}
2015	tabBar := component.NewTabBar()
2016	if tabBar == nil {
2017		t.Fatal("tab bar creation failed")
2018	}
2019	setWindowTabBar(t, mainWindow, tabBar)
2020	bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
2021	cfg := &config.Config{}
2022	cfg.Workspace.HideTabBarWhenSingleTab = true
2023	app := &App{
2024		deps:          &Dependencies{},
2025		runtimeConfig: runtimeConfigStateForTest(cfg),
2026	}
2027
2028	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
2029	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
2030	tabBar.AddTab(tab1)
2031
2032	app.updateBrowserWindowTabBarVisibility(bw)
2033	assertWindowTabBarAutoHidden(t, mainWindow, true)
2034
2035	tabBar.AddTab(tab2)
2036	app.updateBrowserWindowTabBarVisibility(bw)
2037	assertWindowTabBarAutoHidden(t, mainWindow, false)
2038
2039	tabBar.SetVisible(false)
2040	app.updateBrowserWindowTabBarVisibility(bw)
2041	if got := tabBar.Box().GetVisible(); got {
2042		t.Fatalf("tab bar visible = %v, want false while explicitly hidden", got)
2043	}
2044
2045	tabBar.SetVisible(true)
2046	app.updateBrowserWindowTabBarVisibility(bw)
2047	assertWindowTabBarAutoHidden(t, mainWindow, false)
2048
2049	tabBar.RemoveTab(tab2.ID)
2050	app.updateBrowserWindowTabBarVisibility(bw)
2051	assertWindowTabBarAutoHidden(t, mainWindow, true)
2052}
2053
2054func TestTabBarSetVisibleDoesNotClearAutoHiddenInteractionState(t *testing.T) {
2055	gtkApp := requireGTKDisplayApp(t)
2056	defer gtkApp.Unref()
2057
2058	tabBar := component.NewTabBar()
2059	if tabBar == nil {
2060		t.Fatal("tab bar creation failed")
2061	}
2062
2063	tabBar.SetAutoHidden(true)
2064	tabBar.SetVisible(false)
2065	tabBar.SetVisible(true)
2066
2067	box := tabBar.Box()
2068	if !box.GetVisible() {
2069		t.Fatal("tab bar should stay mounted when explicitly visible")
2070	}
2071	if got := box.GetOpacity(); got != 0.0 {
2072		t.Fatalf("tab bar opacity = %v, want 0 while auto-hidden", got)
2073	}
2074	if got := box.GetCanTarget(); got {
2075		t.Fatalf("tab bar can target = %v, want false while auto-hidden", got)
2076	}
2077	if got := box.GetFocusable(); got {
2078		t.Fatalf("tab bar focusable = %v, want false while auto-hidden", got)
2079	}
2080}
2081
2082func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) {
2083	gtkApp := requireGTKDisplayApp(t)
2084	defer gtkApp.Unref()
2085
2086	mainWindow := &window.MainWindow{}
2087	tabBar := component.NewTabBar()
2088	if tabBar == nil {
2089		t.Fatal("tab bar creation failed")
2090	}
2091	setWindowTabBar(t, mainWindow, tabBar)
2092	bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
2093	cfg := &config.Config{}
2094	cfg.Workspace.HideTabBarWhenSingleTab = false
2095	app := &App{
2096		deps:          &Dependencies{},
2097		runtimeConfig: runtimeConfigStateForTest(cfg),
2098	}
2099
2100	app.updateBrowserWindowTabBarVisibility(bw)
2101
2102	assertWindowTabBarAutoHidden(t, mainWindow, false)
2103}
2104
2105func TestApp_ActivePaneIDForNilBrowserWindowIgnoresStaleOverride(t *testing.T) {
2106	ctx := context.Background()
2107	contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2108	contentCoord.SetActivePaneOverride(entity.PaneID("stale-pane"))
2109	app := &App{contentCoord: contentCoord}
2110
2111	if got := app.activePaneIDForBrowserWindow(nil); got != "" {
2112		t.Fatalf("active pane for nil browser window = %q, want empty", got)
2113	}
2114}
2115
2116func TestApp_BuildRestoredWindowUIUpdatesEmptyWindowTabBarVisibility(t *testing.T) {
2117	gtkApp := requireGTKDisplayApp(t)
2118	defer gtkApp.Unref()
2119
2120	mw, err := window.New(context.Background(), gtkApp, "bottom")
2121	if err != nil {
2122		t.Fatalf("window creation failed: %v", err)
2123	}
2124	defer mw.Destroy()
2125
2126	mw.SetTabBarContentInsetVisible(true)
2127	bw := &browserWindow{id: "window-1", mainWindow: mw, tabs: entity.NewTabList()}
2128	cfg := &config.Config{}
2129	cfg.Workspace.HideTabBarWhenSingleTab = true
2130	app := appWithRuntimeConfigForTest(cfg)
2131
2132	app.buildRestoredWindowUI(context.Background(), []*browserWindow{bw})
2133
2134	assertWindowTabBarAutoHidden(t, mw, true)
2135	if mw.HasTabBarContentInset() {
2136		t.Fatal("empty restored bottom window should not keep tab bar content inset")
2137	}
2138}
2139
2140func TestApp_UpdateBrowserWindowTabBarVisibility_BottomInset(t *testing.T) {
2141	gtkApp := requireGTKDisplayApp(t)
2142	defer gtkApp.Unref()
2143
2144	// Use a real bottom MainWindow so SetTabBarContentInsetVisible is effective.
2145	mainWindow, err := window.New(context.Background(), gtkApp, "bottom")
2146	if err != nil {
2147		t.Fatalf("window creation failed: %v", err)
2148	}
2149	defer mainWindow.Destroy()
2150
2151	bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
2152	cfg := &config.Config{}
2153	cfg.Workspace.HideTabBarWhenSingleTab = true
2154	app := &App{
2155		deps:          &Dependencies{},
2156		runtimeConfig: runtimeConfigStateForTest(cfg),
2157	}
2158
2159	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
2160	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
2161
2162	// One tab + hide enabled: auto-hidden and no inset
2163	mainWindow.TabBar().AddTab(tab1)
2164	app.updateBrowserWindowTabBarVisibility(bw)
2165	assertWindowTabBarAutoHidden(t, mainWindow, true)
2166	if mainWindow.HasTabBarContentInset() {
2167		t.Fatal("one tab: expected no content area inset")
2168	}
2169
2170	// Two tabs: visible and inset present
2171	mainWindow.TabBar().AddTab(tab2)
2172	app.updateBrowserWindowTabBarVisibility(bw)
2173	assertWindowTabBarAutoHidden(t, mainWindow, false)
2174	if !mainWindow.HasTabBarContentInset() {
2175		t.Fatal("two tabs: expected content area inset")
2176	}
2177
2178	// Remove back to one tab: auto-hidden and no inset
2179	mainWindow.TabBar().RemoveTab(tab2.ID)
2180	app.updateBrowserWindowTabBarVisibility(bw)
2181	assertWindowTabBarAutoHidden(t, mainWindow, true)
2182	if mainWindow.HasTabBarContentInset() {
2183		t.Fatal("back to one tab: expected no content area inset")
2184	}
2185}
2186
2187func TestApp_HandlePaneFullscreenChanged_ClearsBottomInset(t *testing.T) {
2188	gtkApp := requireGTKDisplayApp(t)
2189	defer gtkApp.Unref()
2190
2191	// Use a bottom MainWindow so SetTabBarContentInsetVisible is effective.
2192	mainWindow, err := window.New(context.Background(), gtkApp, "bottom")
2193	if err != nil {
2194		t.Fatalf("window creation failed: %v", err)
2195	}
2196	defer mainWindow.Destroy()
2197
2198	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
2199	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
2200	mainWindow.TabBar().AddTab(tab1)
2201	mainWindow.TabBar().AddTab(tab2)
2202
2203	tabs := entity.NewTabList()
2204	tabs.Add(tab1)
2205	tabs.Add(tab2)
2206	tabs.SetActive(tab2.ID)
2207
2208	bw := &browserWindow{id: "window-1", mainWindow: mainWindow, tabs: tabs}
2209	cfg := &config.Config{}
2210	cfg.Workspace.HideTabBarWhenSingleTab = true
2211	app := &App{
2212		tabs:                tabs,
2213		deps:                &Dependencies{},
2214		runtimeConfig:       runtimeConfigStateForTest(cfg),
2215		browserWindows:      map[string]*browserWindow{bw.id: bw},
2216		lastFocusedWindowID: bw.id,
2217	}
2218
2219	// Start with two tabs: inset should be present
2220	app.updateBrowserWindowTabBarVisibility(bw)
2221	assertWindowTabBarAutoHidden(t, mainWindow, false)
2222	if !mainWindow.HasTabBarContentInset() {
2223		t.Fatal("two tabs: expected content area inset before fullscreen")
2224	}
2225
2226	// Enter fullscreen: tab bar hidden, inset cleared
2227	app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), true)
2228	if windowTabBarVisible(t, mainWindow) {
2229		t.Fatal("fullscreen: tab bar should be not visible")
2230	}
2231	if mainWindow.HasTabBarContentInset() {
2232		t.Fatal("fullscreen: expected no content area inset")
2233	}
2234
2235	// Exit fullscreen: tab bar restored, inset restored
2236	app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), false)
2237	if !windowTabBarVisible(t, mainWindow) {
2238		t.Fatal("exited fullscreen: tab bar should be visible")
2239	}
2240	if !mainWindow.HasTabBarContentInset() {
2241		t.Fatal("exited fullscreen: expected content area inset to be restored")
2242	}
2243}
2244
2245func TestApp_RemoveBrowserWindowPromotesDeterministicFallbackWithMainWindow(t *testing.T) {
2246	for range 20 {
2247		removed := &browserWindow{id: "window-z", mainWindow: &window.MainWindow{}}
2248		nilWindow := &browserWindow{id: "window-a"}
2249		firstValid := &browserWindow{id: "window-b", mainWindow: &window.MainWindow{}}
2250		secondValid := &browserWindow{id: "window-c", mainWindow: &window.MainWindow{}}
2251		app := &App{
2252			mainWindow: removed.mainWindow,
2253			browserWindows: map[string]*browserWindow{
2254				removed.id:     removed,
2255				nilWindow.id:   nilWindow,
2256				firstValid.id:  firstValid,
2257				secondValid.id: secondValid,
2258			},
2259			browserWindowOrder:  []string{removed.id, nilWindow.id, secondValid.id, firstValid.id},
2260			lastFocusedWindowID: removed.id,
2261		}
2262
2263		app.removeBrowserWindow(removed.id)
2264
2265		if app.mainWindow != secondValid.mainWindow {
2266			t.Fatalf("mainWindow = %p, want %p", app.mainWindow, secondValid.mainWindow)
2267		}
2268		if app.lastFocusedWindowID != secondValid.id {
2269			t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, secondValid.id)
2270		}
2271	}
2272}
2273
2274func TestApp_BrowserWindowActivationHookUpdatesLastFocusedWindowID(t *testing.T) {
2275	first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
2276	second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
2277	app := &App{
2278		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
2279		lastFocusedWindowID: first.id,
2280	}
2281
2282	app.handleBrowserWindowActivationChanged(second, true)
2283
2284	if app.lastFocusedWindowID != second.id {
2285		t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id)
2286	}
2287}
2288
2289func TestApp_ActivateBrowserWindowTracksLastFocusedWindow(t *testing.T) {
2290	first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
2291	second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
2292	app := &App{
2293		browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2294	}
2295
2296	app.activateBrowserWindow(second)
2297
2298	if app.lastFocusedWindowID != second.id {
2299		t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id)
2300	}
2301}
2302
2303func TestApp_LastFocusedBrowserWindowFallsBackWhenOwnerMissing(t *testing.T) {
2304	first := &browserWindow{id: "window-1"}
2305	second := &browserWindow{id: "window-2"}
2306	app := &App{
2307		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
2308		lastFocusedWindowID: second.id,
2309	}
2310
2311	got := app.lastFocusedBrowserWindow()
2312
2313	if got != second {
2314		t.Fatalf("lastFocusedBrowserWindow = %p, want %p", got, second)
2315	}
2316}
2317
2318func TestApp_CreatePopupTabUsesParentPaneOwnerWhenFocusIsStale(t *testing.T) {
2319	ctx := context.Background()
2320	firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2321	secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
2322	globalTabs := entity.NewTabList()
2323	globalTabs.Add(firstTab)
2324	globalTabs.Add(secondTab)
2325	firstTabs := entity.NewTabList()
2326	firstTabs.Add(firstTab)
2327	secondTabs := entity.NewTabList()
2328	secondTabs.Add(secondTab)
2329	first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
2330	second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
2331	app := &App{
2332		deps:                &Dependencies{},
2333		runtimeConfig:       runtimeConfigStateForTest(&config.Config{}),
2334		tabs:                globalTabs,
2335		tabsUC:              usecase.NewManageTabsUseCase(func() string { return "popup-tab" }, nil),
2336		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
2337		windowForTab:        map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
2338		workspaceViews:      make(map[entity.TabID]*component.WorkspaceView),
2339		lastFocusedWindowID: first.id,
2340		mainWindow:          first.mainWindow,
2341	}
2342	app.initTabCoordinator(ctx)
2343	app.tabCoord.SetOnAttachPopupToTab(func(context.Context, entity.TabID, *entity.Pane, port.WebView) error { return nil })
2344
2345	err := app.createPopupTab(ctx, contentcoord.InsertPopupInput{
2346		ParentPaneID: secondTab.Workspace.ActivePaneID,
2347		PopupPane:    entity.NewPane(entity.PaneID("popup-pane")),
2348		TargetURI:    "https://example.com/popup",
2349	})
2350	if err != nil {
2351		t.Fatalf("createPopupTab returned error: %v", err)
2352	}
2353
2354	if first.tabs.Find(entity.TabID("popup-tab")) != nil {
2355		t.Fatalf("popup tab was added to stale focused window")
2356	}
2357	created := second.tabs.Find(entity.TabID("popup-tab"))
2358	if created == nil {
2359		t.Fatalf("popup tab was not added to parent pane owner window")
2360	}
2361	if got := app.windowForTab[created.ID]; got != second {
2362		t.Fatalf("popup tab owner = %p, want %p", got, second)
2363	}
2364}
2365
2366func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) {
2367	first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
2368	second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
2369	app := &App{
2370		mainWindow:          second.mainWindow,
2371		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
2372		lastFocusedWindowID: second.id,
2373	}
2374
2375	app.removeBrowserWindow(second.id)
2376
2377	if app.lastFocusedWindowID != first.id {
2378		t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, first.id)
2379	}
2380}
2381
2382func TestApp_TabCoordinatorCallbacksUseTargetWindowWhenFocusIsStale(t *testing.T) {
2383	ctx := context.Background()
2384	firstTabs := entity.NewTabList()
2385	firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2386	firstTabs.Add(firstTab)
2387	secondTabs := entity.NewTabList()
2388	secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
2389	secondTabs.Add(secondTab)
2390
2391	first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
2392	second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
2393	app := &App{
2394		deps:                &Dependencies{},
2395		runtimeConfig:       runtimeConfigStateForTest(&config.Config{}),
2396		tabs:                entity.NewTabList(),
2397		tabsUC:              usecase.NewManageTabsUseCase(func() string { return "created-tab" }, nil),
2398		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
2399		lastFocusedWindowID: first.id,
2400		mainWindow:          first.mainWindow,
2401		windowForTab:        map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
2402		workspaceViews:      make(map[entity.TabID]*component.WorkspaceView),
2403	}
2404	app.tabs.Add(firstTab)
2405	app.tabs.Add(secondTab)
2406	app.initTabCoordinator(ctx)
2407
2408	created, err := app.tabCoord.Create(ctx, app.tabTargetForBrowserWindow(second), "https://example.com")
2409	if err != nil {
2410		t.Fatalf("Create returned error: %v", err)
2411	}
2412	if got := app.windowForTab[created.ID]; got != second {
2413		t.Fatalf("created tab owner = %p, want second window %p", got, second)
2414	}
2415	if app.mainWindow != second.mainWindow {
2416		t.Fatalf("mainWindow after create switch = %p, want %p", app.mainWindow, second.mainWindow)
2417	}
2418
2419	app.mainWindow = first.mainWindow
2420	app.lastFocusedWindowID = first.id
2421	if err := app.tabCoord.Switch(ctx, app.tabTargetForBrowserWindow(second), secondTab.ID); err != nil {
2422		t.Fatalf("Switch returned error: %v", err)
2423	}
2424	if app.mainWindow != second.mainWindow {
2425		t.Fatalf("mainWindow after target switch = %p, want %p", app.mainWindow, second.mainWindow)
2426	}
2427}
2428
2429func TestApp_ActivateBrowserWindowAddsGlobalSnapshotWithoutChangingWindowTabPosition(t *testing.T) {
2430	firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2431	secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
2432	firstTabs := entity.NewTabList()
2433	firstTabs.Add(firstTab)
2434	secondTabs := entity.NewTabList()
2435	secondTabs.Add(secondTab)
2436	secondTabs.SetActive(secondTab.ID)
2437	first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
2438	second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
2439	globalTabs := entity.NewTabList()
2440	globalTabs.Add(firstTab)
2441	app := &App{
2442		tabs:           globalTabs,
2443		browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2444	}
2445
2446	app.activateBrowserWindow(second)
2447
2448	if secondTab.Position != 0 {
2449		t.Fatalf("window tab position = %d, want 0", secondTab.Position)
2450	}
2451	globalSecond := app.tabs.Find(secondTab.ID)
2452	if globalSecond == nil {
2453		t.Fatalf("global tab snapshot was not added")
2454	}
2455	if globalSecond == secondTab {
2456		t.Fatalf("global tab should be a snapshot, not the live per-window tab")
2457	}
2458	if globalSecond.Position != 1 {
2459		t.Fatalf("global tab position = %d, want 1", globalSecond.Position)
2460	}
2461}
2462
2463func TestApp_ActiveWorkspaceRequiresFocusedBrowserWindow(t *testing.T) {
2464	globalTab := entity.NewTab(entity.TabID("global-tab"), entity.WorkspaceID("ws-global"), entity.NewPane(entity.PaneID("pane-global")))
2465	globalTabs := entity.NewTabList()
2466	globalTabs.Add(globalTab)
2467	globalTabs.SetActive(globalTab.ID)
2468	app := &App{
2469		tabs:           globalTabs,
2470		browserWindows: map[string]*browserWindow{},
2471		workspaceViews: map[entity.TabID]*component.WorkspaceView{globalTab.ID: {}},
2472	}
2473
2474	if got := app.activeWorkspace(); got != nil {
2475		t.Fatalf("activeWorkspace() = %p, want nil without focused browser window", got)
2476	}
2477	if got := app.activeWorkspaceView(); got != nil {
2478		t.Fatalf("activeWorkspaceView() = %p, want nil without focused browser window", got)
2479	}
2480}
2481
2482func TestApp_ActiveWorkspaceUsesLastFocusedWindowTabsNotGlobalActiveTab(t *testing.T) {
2483	firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2484	secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
2485	firstTabs := entity.NewTabList()
2486	firstTabs.Add(firstTab)
2487	firstTabs.SetActive(firstTab.ID)
2488	secondTabs := entity.NewTabList()
2489	secondTabs.Add(secondTab)
2490	secondTabs.SetActive(secondTab.ID)
2491	first := &browserWindow{id: "window-1", tabs: firstTabs}
2492	second := &browserWindow{id: "window-2", tabs: secondTabs}
2493	globalTabs := entity.NewTabList()
2494	globalTabs.Add(firstTab)
2495	globalTabs.Add(secondTab)
2496	globalTabs.SetActive(firstTab.ID)
2497	secondView := &component.WorkspaceView{}
2498	app := &App{
2499		tabs:                globalTabs,
2500		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
2501		lastFocusedWindowID: second.id,
2502		workspaceViews:      map[entity.TabID]*component.WorkspaceView{secondTab.ID: secondView},
2503	}
2504
2505	if got := app.activeWorkspace(); got != secondTab.Workspace {
2506		t.Fatalf("activeWorkspace() = %p, want second workspace %p", got, secondTab.Workspace)
2507	}
2508	if got := app.activeWorkspaceView(); got != secondView {
2509		t.Fatalf("activeWorkspaceView() = %p, want second view %p", got, secondView)
2510	}
2511}
2512
2513// recordingWebView is a complete port.WebView that records which navigation
2514// methods were called and with what arguments. It does not use reflect or unsafe.
2515type recordingWebView struct {
2516	id port.WebViewID
2517
2518	loadURICalled          bool
2519	loadURILastURI         string
2520	reloadCalls            int
2521	reloadBypassCacheCalls int
2522	stopCalls              int
2523	goBackCalls            int
2524	goForwardCalls         int
2525	loadHTMLCalls          int
2526	setZoomLevelCalls      int
2527	setZoomLevelLastLevel  float64
2528	openDevToolsCalls      int
2529	printPageCalls         int
2530	destroyCalls           int
2531}
2532
2533func (f *recordingWebView) ID() port.WebViewID { return f.id }
2534
2535func (f *recordingWebView) LoadURI(_ context.Context, uri string) error {
2536	f.loadURICalled = true
2537	f.loadURILastURI = uri
2538	return nil
2539}
2540
2541func (f *recordingWebView) LoadHTML(_ context.Context, _, _ string) error {
2542	f.loadHTMLCalls++
2543	return nil
2544}
2545
2546func (f *recordingWebView) Reload(_ context.Context) error {
2547	f.reloadCalls++
2548	return nil
2549}
2550
2551func (f *recordingWebView) ReloadBypassCache(_ context.Context) error {
2552	f.reloadBypassCacheCalls++
2553	return nil
2554}
2555
2556func (f *recordingWebView) Stop(_ context.Context) error {
2557	f.stopCalls++
2558	return nil
2559}
2560
2561func (f *recordingWebView) GoBack(_ context.Context) error {
2562	f.goBackCalls++
2563	return nil
2564}
2565
2566func (f *recordingWebView) GoForward(_ context.Context) error {
2567	f.goForwardCalls++
2568	return nil
2569}
2570
2571func (f *recordingWebView) State() port.WebViewState { return port.WebViewState{} }
2572
2573func (f *recordingWebView) URI() string   { return f.loadURILastURI }
2574func (f *recordingWebView) Title() string { return "" }
2575
2576func (f *recordingWebView) IsLoading() bool            { return false }
2577func (f *recordingWebView) EstimatedProgress() float64 { return 0 }
2578func (f *recordingWebView) CanGoBack() bool            { return false }
2579func (f *recordingWebView) CanGoForward() bool         { return false }
2580
2581func (f *recordingWebView) SetZoomLevel(_ context.Context, level float64) error {
2582	f.setZoomLevelCalls++
2583	f.setZoomLevelLastLevel = level
2584	return nil
2585}
2586
2587func (f *recordingWebView) OpenDevTools() { f.openDevToolsCalls++ }
2588func (f *recordingWebView) PrintPage()    { f.printPageCalls++ }
2589
2590func (f *recordingWebView) GetZoomLevel() float64                     { return 1.0 }
2591func (f *recordingWebView) GetFindController() port.FindController    { return nil }
2592func (f *recordingWebView) SetCallbacks(_ *port.WebViewCallbacks)     {}
2593func (f *recordingWebView) RunJavaScript(_ context.Context, _ string) {}
2594func (f *recordingWebView) SetBackgroundColor(_, _, _, _ float64)     {}
2595func (f *recordingWebView) ResetBackgroundToDefault()                 {}
2596func (f *recordingWebView) Favicon() port.Texture                     { return nil }
2597func (f *recordingWebView) Generation() uint64                        { return 0 }
2598func (f *recordingWebView) IsFullscreen() bool                        { return false }
2599func (f *recordingWebView) IsPlayingAudio() bool                      { return false }
2600func (f *recordingWebView) IsDestroyed() bool                         { return f.destroyCalls > 0 }
2601func (f *recordingWebView) Destroy()                                  { f.destroyCalls++ }
2602
2603func TestApp_AttachPopupToTabLeavesPopupOwnedByCallerWhenWorkspaceViewMissing(t *testing.T) {
2604	ctx := context.Background()
2605	popupWV := &recordingWebView{id: 1}
2606	app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{}}
2607
2608	err := app.attachPopupToTab(ctx, entity.TabID("missing-tab"), entity.NewPane(entity.PaneID("popup-pane")), popupWV)
2609
2610	if err == nil || popupWV.destroyCalls != 0 {
2611		t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls)
2612	}
2613}
2614
2615func TestApp_AttachPopupToTabLeavesPopupOwnedByCallerWhenPaneNil(t *testing.T) {
2616	ctx := context.Background()
2617	popupWV := &recordingWebView{id: 1}
2618	tabID := entity.TabID("tab-1")
2619	app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{tabID: &component.WorkspaceView{}}}
2620
2621	err := app.attachPopupToTab(ctx, tabID, nil, popupWV)
2622
2623	if err == nil || popupWV.destroyCalls != 0 {
2624		t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls)
2625	}
2626}
2627
2628func TestApp_AttachPopupToTabSkipsRegistrationWhenPaneViewMissing(t *testing.T) {
2629	ctx := context.Background()
2630	contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2631	tabID := entity.TabID("tab-1")
2632	pane := entity.NewPane(entity.PaneID("missing-pane"))
2633	app := &App{
2634		contentCoord: contentCoord,
2635		workspaceViews: map[entity.TabID]*component.WorkspaceView{
2636			tabID: &component.WorkspaceView{},
2637		},
2638	}
2639
2640	popupWV := &recordingWebView{id: 1}
2641	err := app.attachPopupToTab(ctx, tabID, pane, popupWV)
2642
2643	if got := contentCoord.GetWebView(pane.ID); got != nil {
2644		t.Fatalf("popup webview was registered for missing pane view: %v", got)
2645	}
2646	if err == nil || popupWV.destroyCalls != 0 {
2647		t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls)
2648	}
2649}
2650
2651func TestApp_AttachPopupToTabReleasesRegistrationWhenWrapFails(t *testing.T) {
2652	ctx := context.Background()
2653	contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2654	tabID := entity.TabID("tab-1")
2655	pane := entity.NewPane(entity.PaneID("popup-pane"))
2656
2657	factory := layoutmocks.NewMockWidgetFactory(t)
2658	container := layoutmocks.NewMockBoxWidget(t)
2659	overlay := layoutmocks.NewMockOverlayWidget(t)
2660	factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(container).Once()
2661	container.EXPECT().SetHexpand(true).Once()
2662	container.EXPECT().SetVexpand(true).Once()
2663	container.EXPECT().SetVisible(true).Once()
2664	factory.EXPECT().NewOverlay().Return(overlay).Once()
2665	overlay.EXPECT().SetHexpand(true).Once()
2666	overlay.EXPECT().SetVexpand(true).Once()
2667	overlay.EXPECT().SetChild(container).Once()
2668	overlay.EXPECT().SetVisible(true).Once()
2669
2670	wsView := component.NewWorkspaceView(ctx, factory)
2671	container.EXPECT().AddCssClass("single-pane").Once()
2672	wsView.RegisterPaneView(pane.ID, &component.PaneView{})
2673
2674	app := &App{
2675		contentCoord: contentCoord,
2676		workspaceViews: map[entity.TabID]*component.WorkspaceView{
2677			tabID: wsView,
2678		},
2679	}
2680	popupWV := &recordingWebView{id: 1}
2681
2682	err := app.attachPopupToTab(ctx, tabID, pane, popupWV)
2683
2684	if got := contentCoord.GetWebView(pane.ID); got != nil {
2685		t.Fatalf("popup webview registration remained after wrap failure: %v", got)
2686	}
2687	if err == nil || popupWV.destroyCalls != 0 {
2688		t.Fatalf("error = %v, popup webview destroy calls = %d, want error and 0", err, popupWV.destroyCalls)
2689	}
2690}
2691
2692func TestApp_BrowserWindowWebViewActionsIgnoreStaleFocusedWindow(t *testing.T) {
2693	ctx := context.Background()
2694
2695	// Build two browser windows with independent bw.tabs and active tabs/panes.
2696	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2697	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
2698
2699	firstTabs := entity.NewTabList()
2700	firstTabs.Add(tab1)
2701	firstTabs.SetActive(tab1.ID)
2702	first := &browserWindow{id: "window-1", tabs: firstTabs}
2703
2704	secondTabs := entity.NewTabList()
2705	secondTabs.Add(tab2)
2706	secondTabs.SetActive(tab2.ID)
2707	second := &browserWindow{id: "window-2", tabs: secondTabs}
2708
2709	// Create recording webviews.
2710	recordingWv1 := &recordingWebView{id: 1}
2711	recordingWv2 := &recordingWebView{id: 2}
2712
2713	// Create content coordinator with initialized internal maps to avoid
2714	// nil-map panics in SetNavigationOrigin during NavigateWebView calls.
2715	contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2716	contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), recordingWv1)
2717	contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), recordingWv2)
2718
2719	// Create NavigationCoordinator (no navigateUC needed for direct webview calls).
2720	navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
2721
2722	app := &App{
2723		tabs:                entity.NewTabList(),
2724		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
2725		lastFocusedWindowID: first.id, // stale! should NOT be used
2726		contentCoord:        contentCoord,
2727		navCoord:            navCoord,
2728		workspaceViews: map[entity.TabID]*component.WorkspaceView{
2729			tab1.ID: &component.WorkspaceView{},
2730			tab2.ID: &component.WorkspaceView{},
2731		},
2732	}
2733	app.tabs.Add(tab1)
2734	app.tabs.Add(tab2)
2735
2736	// Call window-scoped app helpers for the second window.
2737	if err := app.navigateFromBrowserWindow(ctx, second, "https://google.com"); err != nil {
2738		t.Fatalf("navigateFromBrowserWindow returned error: %v", err)
2739	}
2740	if err := app.reloadBrowserWindow(ctx, second, false); err != nil {
2741		t.Fatalf("reloadBrowserWindow (soft) returned error: %v", err)
2742	}
2743	if err := app.reloadBrowserWindow(ctx, second, true); err != nil {
2744		t.Fatalf("reloadBrowserWindow (hard) returned error: %v", err)
2745	}
2746	if err := app.stopBrowserWindow(ctx, second); err != nil {
2747		t.Fatalf("stopBrowserWindow returned error: %v", err)
2748	}
2749	if err := app.goBackBrowserWindow(ctx, second); err != nil {
2750		t.Fatalf("goBackBrowserWindow returned error: %v", err)
2751	}
2752	if err := app.goForwardBrowserWindow(ctx, second); err != nil {
2753		t.Fatalf("goForwardBrowserWindow returned error: %v", err)
2754	}
2755
2756	assertRecordingWebViewIdle(t, "first", recordingWv1)
2757	assertRecordingWebViewActions(t, "second", recordingWv2, "https://google.com")
2758}
2759
2760func assertRecordingWebViewIdle(t *testing.T, name string, wv *recordingWebView) {
2761	t.Helper()
2762	if wv.loadURICalled || wv.loadURILastURI != "" {
2763		t.Errorf("%s webview LoadURI was called, expected zero calls", name)
2764	}
2765	if wv.reloadCalls > 0 {
2766		t.Errorf("%s webview Reload calls = %d, want 0", name, wv.reloadCalls)
2767	}
2768	if wv.reloadBypassCacheCalls > 0 {
2769		t.Errorf("%s webview ReloadBypassCache calls = %d, want 0", name, wv.reloadBypassCacheCalls)
2770	}
2771	if wv.stopCalls > 0 {
2772		t.Errorf("%s webview Stop calls = %d, want 0", name, wv.stopCalls)
2773	}
2774	if wv.goBackCalls > 0 {
2775		t.Errorf("%s webview GoBack calls = %d, want 0", name, wv.goBackCalls)
2776	}
2777	if wv.goForwardCalls > 0 {
2778		t.Errorf("%s webview GoForward calls = %d, want 0", name, wv.goForwardCalls)
2779	}
2780}
2781
2782func assertRecordingWebViewActions(t *testing.T, name string, wv *recordingWebView, wantURI string) {
2783	t.Helper()
2784	if !wv.loadURICalled {
2785		t.Errorf("%s webview LoadURI was not called", name)
2786	}
2787	if wv.loadURILastURI != wantURI {
2788		t.Errorf("%s webview LoadURI URI = %q, want %q", name, wv.loadURILastURI, wantURI)
2789	}
2790	if wv.reloadCalls != 1 {
2791		t.Errorf("%s webview Reload calls = %d, want 1", name, wv.reloadCalls)
2792	}
2793	if wv.reloadBypassCacheCalls != 1 {
2794		t.Errorf("%s webview ReloadBypassCache calls = %d, want 1", name, wv.reloadBypassCacheCalls)
2795	}
2796	if wv.stopCalls != 1 {
2797		t.Errorf("%s webview Stop calls = %d, want 1", name, wv.stopCalls)
2798	}
2799	if wv.goBackCalls != 1 {
2800		t.Errorf("%s webview GoBack calls = %d, want 1", name, wv.goBackCalls)
2801	}
2802	if wv.goForwardCalls != 1 {
2803		t.Errorf("%s webview GoForward calls = %d, want 1", name, wv.goForwardCalls)
2804	}
2805}
2806
2807func assertRecordingWebViewBrowserActionsIdle(t *testing.T, name string, wv *recordingWebView) {
2808	t.Helper()
2809	if wv.reloadCalls > 0 {
2810		t.Errorf("%s webview Reload calls = %d, want 0", name, wv.reloadCalls)
2811	}
2812	if wv.reloadBypassCacheCalls > 0 {
2813		t.Errorf("%s webview ReloadBypassCache calls = %d, want 0", name, wv.reloadBypassCacheCalls)
2814	}
2815	if wv.stopCalls > 0 {
2816		t.Errorf("%s webview Stop calls = %d, want 0", name, wv.stopCalls)
2817	}
2818	if wv.goBackCalls > 0 {
2819		t.Errorf("%s webview GoBack calls = %d, want 0", name, wv.goBackCalls)
2820	}
2821	if wv.goForwardCalls > 0 {
2822		t.Errorf("%s webview GoForward calls = %d, want 0", name, wv.goForwardCalls)
2823	}
2824	if wv.printPageCalls > 0 {
2825		t.Errorf("%s webview PrintPage calls = %d, want 0", name, wv.printPageCalls)
2826	}
2827	if wv.openDevToolsCalls > 0 {
2828		t.Errorf("%s webview OpenDevTools calls = %d, want 0", name, wv.openDevToolsCalls)
2829	}
2830	if wv.setZoomLevelCalls > 0 {
2831		t.Errorf("%s webview SetZoomLevel calls = %d, want 0", name, wv.setZoomLevelCalls)
2832	}
2833}
2834
2835func assertRecordingWebViewBrowserActionsCalledOnce(t *testing.T, name string, wv *recordingWebView) {
2836	t.Helper()
2837	if wv.reloadCalls != 1 {
2838		t.Errorf("%s webview Reload calls = %d, want 1", name, wv.reloadCalls)
2839	}
2840	if wv.reloadBypassCacheCalls != 1 {
2841		t.Errorf("%s webview ReloadBypassCache calls = %d, want 1", name, wv.reloadBypassCacheCalls)
2842	}
2843	if wv.stopCalls != 1 {
2844		t.Errorf("%s webview Stop calls = %d, want 1", name, wv.stopCalls)
2845	}
2846	if wv.goBackCalls != 1 {
2847		t.Errorf("%s webview GoBack calls = %d, want 1", name, wv.goBackCalls)
2848	}
2849	if wv.goForwardCalls != 1 {
2850		t.Errorf("%s webview GoForward calls = %d, want 1", name, wv.goForwardCalls)
2851	}
2852	if wv.printPageCalls != 1 {
2853		t.Errorf("%s webview PrintPage calls = %d, want 1", name, wv.printPageCalls)
2854	}
2855	if wv.openDevToolsCalls != 1 {
2856		t.Errorf("%s webview OpenDevTools calls = %d, want 1", name, wv.openDevToolsCalls)
2857	}
2858	if wv.setZoomLevelCalls != 1 {
2859		t.Errorf("%s webview SetZoomLevel calls = %d, want 1", name, wv.setZoomLevelCalls)
2860	}
2861}
2862
2863func TestApp_OmniboxNavigateCallbackCapturesBrowserWindow(t *testing.T) {
2864	second := &browserWindow{id: "window-2"}
2865
2866	var capturedBW *browserWindow
2867	var capturedURL string
2868	testNavigate := func(_ context.Context, bw *browserWindow, url string) error {
2869		capturedBW = bw
2870		capturedURL = url
2871		return nil
2872	}
2873
2874	ctx := context.Background()
2875	cb := omniboxNavigateForBrowserWindow(ctx, second, testNavigate)
2876	if err := cb(ctx, "https://google.com"); err != nil {
2877		t.Fatalf("omnibox navigate callback returned error: %v", err)
2878	}
2879
2880	if capturedBW != second {
2881		capturedID := "<nil>"
2882		if capturedBW != nil {
2883			capturedID = capturedBW.id
2884		}
2885		t.Errorf("captured browser window = %p (id=%s), want %p (id=%s)", capturedBW, capturedID, second, second.id)
2886	}
2887	if capturedURL != "https://google.com" {
2888		t.Errorf("captured URL = %q, want %q", capturedURL, "https://google.com")
2889	}
2890}
2891
2892func TestApp_DispatchBrowserWindowActionUsesSourceWindow(t *testing.T) {
2893	ctx := context.Background()
2894
2895	// Build two browser windows with independent bw.tabs and active panes.
2896	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2897	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
2898
2899	firstTabs := entity.NewTabList()
2900	firstTabs.Add(tab1)
2901	firstTabs.SetActive(tab1.ID)
2902	first := &browserWindow{id: "window-1", tabs: firstTabs}
2903
2904	secondTabs := entity.NewTabList()
2905	secondTabs.Add(tab2)
2906	secondTabs.SetActive(tab2.ID)
2907	second := &browserWindow{id: "window-2", tabs: secondTabs}
2908
2909	// Create recording webviews.
2910	recordingWv1 := &recordingWebView{id: 1, loadURILastURI: "https://first.example"}
2911	recordingWv2 := &recordingWebView{id: 2, loadURILastURI: "https://second.example"}
2912
2913	// Register recording webviews in content coordinator.
2914	contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2915	contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), recordingWv1)
2916	contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), recordingWv2)
2917
2918	// Create navCoord (no navigateUC needed for direct webview calls).
2919	navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
2920
2921	app := &App{
2922		tabs:                entity.NewTabList(),
2923		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
2924		lastFocusedWindowID: first.id, // stale! should NOT be used
2925		contentCoord:        contentCoord,
2926		navCoord:            navCoord,
2927		deps:                &Dependencies{ZoomUC: usecase.NewManageZoomUseCase(mockZoomRepo(t), 1.0, nil)},
2928		workspaceViews: map[entity.TabID]*component.WorkspaceView{
2929			tab1.ID: &component.WorkspaceView{},
2930			tab2.ID: &component.WorkspaceView{},
2931		},
2932	}
2933	app.tabs.Add(tab1)
2934	app.tabs.Add(tab2)
2935
2936	// Dispatch browser window actions targeting the second window.
2937	for _, a := range []input.Action{
2938		input.ActionReload,
2939		input.ActionHardReload,
2940		input.ActionStop,
2941		input.ActionGoBack,
2942		input.ActionGoForward,
2943		input.ActionPrintPage,
2944		input.ActionOpenDevTools,
2945		input.ActionZoomIn,
2946	} {
2947		if err := app.dispatchBrowserWindowAction(ctx, second, a); err != nil {
2948			t.Fatalf("dispatchBrowserWindowAction (%s) returned error: %v", a, err)
2949		}
2950	}
2951
2952	// Assert first recording webview receives zero calls (stale focus is ignored).
2953	assertRecordingWebViewBrowserActionsIdle(t, "first", recordingWv1)
2954
2955	// Assert second recording webview receives exactly one call per action.
2956	assertRecordingWebViewBrowserActionsCalledOnce(t, "second", recordingWv2)
2957}
2958
2959func TestApp_DispatchBrowserWindowActionZoomInSupportsFileURLs(t *testing.T) {
2960	ctx := context.Background()
2961
2962	tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2963	tabs := entity.NewTabList()
2964	tabs.Add(tab)
2965	tabs.SetActive(tab.ID)
2966	bw := &browserWindow{id: "window-1", tabs: tabs}
2967
2968	recordingWv := &recordingWebView{id: 1, loadURILastURI: "file:///tmp/demo.html"}
2969	contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2970	contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), recordingWv)
2971
2972	app := &App{
2973		browserWindows: map[string]*browserWindow{bw.id: bw},
2974		tabs:           entity.NewTabList(),
2975		windowForTab:   map[entity.TabID]*browserWindow{tab.ID: bw},
2976		contentCoord:   contentCoord,
2977		deps:           &Dependencies{ZoomUC: usecase.NewManageZoomUseCase(mockZoomRepo(t), 1.0, nil)},
2978		workspaceViews: map[entity.TabID]*component.WorkspaceView{
2979			tab.ID: &component.WorkspaceView{},
2980		},
2981	}
2982	app.tabs.Add(tab)
2983	app.tabs.SetActive(tab.ID)
2984
2985	if err := app.dispatchBrowserWindowAction(ctx, bw, input.ActionZoomIn); err != nil {
2986		t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
2987	}
2988	if recordingWv.setZoomLevelCalls != 1 {
2989		t.Fatalf("webview SetZoomLevel calls = %d, want 1", recordingWv.setZoomLevelCalls)
2990	}
2991	if diff := math.Abs(recordingWv.setZoomLevelLastLevel - 1.1); diff > 0.0001 {
2992		t.Fatalf("webview SetZoomLevel level = %f, want 1.1", recordingWv.setZoomLevelLastLevel)
2993	}
2994}
2995
2996func counterIDGen() func() string {
2997	var counter int
2998	return func() string {
2999		counter++
3000		return strconv.Itoa(counter)
3001	}
3002}
3003
3004func TestApp_DispatchBrowserWindowActionSwitchTabIndexUsesSourceWindow(t *testing.T) {
3005	ctx := context.Background()
3006
3007	firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
3008	firstTabs := entity.NewTabList()
3009	firstTabs.Add(firstTab)
3010	firstTabs.SetActive(firstTab.ID)
3011	first := &browserWindow{id: "window-1", tabs: firstTabs}
3012
3013	secondTab1 := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
3014	secondTab2 := entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2")))
3015	secondTabs := entity.NewTabList()
3016	secondTabs.Add(secondTab1)
3017	secondTabs.Add(secondTab2)
3018	secondTabs.SetActive(secondTab1.ID)
3019	second := &browserWindow{id: "window-2", tabs: secondTabs}
3020
3021	cfg := &config.Config{
3022		Workspace: config.WorkspaceConfig{
3023			NewPaneURL: "about:blank",
3024		},
3025	}
3026	app := &App{
3027		deps:                &Dependencies{},
3028		runtimeConfig:       runtimeConfigStateForTest(cfg),
3029		tabsUC:              usecase.NewManageTabsUseCase(counterIDGen(), nil),
3030		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
3031		windowForTab:        map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab1.ID: second, secondTab2.ID: second},
3032		lastFocusedWindowID: first.id, // stale — should NOT be used
3033	}
3034	app.initTabCoordinator(ctx)
3035
3036	err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex2)
3037	if err != nil {
3038		t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
3039	}
3040
3041	if got := secondTabs.ActiveTabID; got != secondTab2.ID {
3042		t.Fatalf("second active tab = %q, want %q", got, secondTab2.ID)
3043	}
3044	if got := firstTabs.ActiveTabID; got != firstTab.ID {
3045		t.Fatalf("first active tab = %q, want %q", got, firstTab.ID)
3046	}
3047}
3048
3049func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesOnlyOneMissingTab(t *testing.T) {
3050	ctx := context.Background()
3051
3052	firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
3053	firstTabs := entity.NewTabList()
3054	firstTabs.Add(firstTab)
3055	firstTabs.SetActive(firstTab.ID)
3056	first := &browserWindow{id: "window-1", tabs: firstTabs}
3057
3058	secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
3059	secondTabs := entity.NewTabList()
3060	secondTabs.Add(secondTab)
3061	secondTabs.SetActive(secondTab.ID)
3062	second := &browserWindow{id: "window-2", tabs: secondTabs}
3063
3064	cfg := &config.Config{
3065		Workspace: config.WorkspaceConfig{
3066			NewPaneURL: "about:blank",
3067		},
3068	}
3069	app := &App{
3070		deps:                &Dependencies{},
3071		runtimeConfig:       runtimeConfigStateForTest(cfg),
3072		tabsUC:              usecase.NewManageTabsUseCase(counterIDGen(), nil),
3073		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
3074		windowForTab:        map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
3075		lastFocusedWindowID: first.id, // stale — should NOT be used
3076	}
3077	app.initTabCoordinator(ctx)
3078
3079	// Dispatch ActionSwitchTabIndex4 targeting the second window.
3080	err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex4)
3081	if err != nil {
3082		t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
3083	}
3084
3085	// Out-of-range switches should create a single new tab in the source window.
3086	if got := secondTabs.Count(); got != 2 {
3087		t.Fatalf("second target tab count = %d, want 2", got)
3088	}
3089	if got := secondTabs.ActiveTabID; got != secondTabs.Tabs[1].ID {
3090		t.Fatalf("active tab = %q, want %q", got, secondTabs.Tabs[1].ID)
3091	}
3092
3093	if got := firstTabs.Count(); got != 1 {
3094		t.Fatalf("first target tab count = %d, want 1", got)
3095	}
3096	if got := firstTabs.ActiveTabID; got != firstTab.ID {
3097		t.Fatalf("first active tab = %q, want %q", got, firstTab.ID)
3098	}
3099
3100	if got := app.windowForTab[secondTabs.Tabs[1].ID]; got != second {
3101		t.Fatalf("windowForTab[%s] = %p, want second window %p", secondTabs.Tabs[1].ID, got, second)
3102	}
3103}
3104
3105func TestApp_SwitchBrowserWindowTabIndexNegativeIndexIsIgnored(t *testing.T) {
3106	ctx := context.Background()
3107
3108	secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
3109	secondTabs := entity.NewTabList()
3110	secondTabs.Add(secondTab)
3111	secondTabs.SetActive(secondTab.ID)
3112	second := &browserWindow{id: "window-2", tabs: secondTabs}
3113
3114	cfg := &config.Config{Workspace: config.WorkspaceConfig{NewPaneURL: "about:blank"}}
3115	app := &App{
3116		deps:           &Dependencies{},
3117		runtimeConfig:  runtimeConfigStateForTest(cfg),
3118		tabsUC:         usecase.NewManageTabsUseCase(counterIDGen(), nil),
3119		browserWindows: map[string]*browserWindow{second.id: second},
3120		windowForTab:   map[entity.TabID]*browserWindow{secondTab.ID: second},
3121	}
3122	app.initTabCoordinator(ctx)
3123
3124	err := app.switchBrowserWindowTabIndex(ctx, second, -1)
3125	if err != nil {
3126		t.Fatalf("switchBrowserWindowTabIndex returned error: %v", err)
3127	}
3128	if got := secondTabs.Count(); got != 1 {
3129		t.Fatalf("second target tab count = %d, want 1", got)
3130	}
3131	if got := secondTabs.ActiveTabID; got != secondTab.ID {
3132		t.Fatalf("active tab = %q, want %q", got, secondTab.ID)
3133	}
3134}
3135
3136func TestApp_DispatchBrowserWindowActionSwitchTabIndexNegativeDoesNotMutateWindowState(t *testing.T) {
3137	ctx := context.Background()
3138
3139	second := &browserWindow{id: "window-2"}
3140
3141	cfg := &config.Config{Workspace: config.WorkspaceConfig{NewPaneURL: "about:blank"}}
3142	app := &App{
3143		deps:                &Dependencies{},
3144		runtimeConfig:       runtimeConfigStateForTest(cfg),
3145		tabsUC:              usecase.NewManageTabsUseCase(counterIDGen(), nil),
3146		browserWindows:      map[string]*browserWindow{second.id: second},
3147		lastFocusedWindowID: "window-1",
3148	}
3149	app.initTabCoordinator(ctx)
3150
3151	err := app.switchBrowserWindowTabIndex(ctx, second, -1)
3152	if err != nil {
3153		t.Fatalf("switchBrowserWindowTabIndex returned error: %v", err)
3154	}
3155	if got := app.lastFocusedWindowID; got != "window-1" {
3156		t.Fatalf("lastFocusedWindowID = %q, want %q", got, "window-1")
3157	}
3158	if second.tabs != nil {
3159		t.Fatalf("second tabs should stay nil for invalid index")
3160	}
3161}
3162
3163func TestApp_DispatchBrowserWindowActionSwitchTabIndexMissingURLDoesNotCreateTab(t *testing.T) {
3164	ctx := context.Background()
3165
3166	secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
3167	secondTabs := entity.NewTabList()
3168	secondTabs.Add(secondTab)
3169	secondTabs.SetActive(secondTab.ID)
3170	second := &browserWindow{id: "window-2", tabs: secondTabs}
3171
3172	app := &App{
3173		deps:           &Dependencies{},
3174		runtimeConfig:  runtimeConfigStateForTest(&config.Config{}),
3175		tabsUC:         usecase.NewManageTabsUseCase(counterIDGen(), nil),
3176		browserWindows: map[string]*browserWindow{second.id: second},
3177		windowForTab:   map[entity.TabID]*browserWindow{secondTab.ID: second},
3178	}
3179	app.initTabCoordinator(ctx)
3180
3181	err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex4)
3182	if err == nil {
3183		t.Fatal("dispatchBrowserWindowAction returned nil error, want config error")
3184	}
3185	if got := secondTabs.Count(); got != 1 {
3186		t.Fatalf("second target tab count = %d, want 1", got)
3187	}
3188	if got := secondTabs.ActiveTabID; got != secondTab.ID {
3189		t.Fatalf("active tab = %q, want %q", got, secondTab.ID)
3190	}
3191}
3192
3193func TestApp_SwitchBrowserWindowTabIndexMissingURLDoesNotMutateWindowState(t *testing.T) {
3194	ctx := context.Background()
3195
3196	second := &browserWindow{id: "window-2"}
3197
3198	app := &App{
3199		deps:                &Dependencies{},
3200		runtimeConfig:       runtimeConfigStateForTest(&config.Config{}),
3201		tabsUC:              usecase.NewManageTabsUseCase(counterIDGen(), nil),
3202		browserWindows:      map[string]*browserWindow{second.id: second},
3203		lastFocusedWindowID: "window-1",
3204	}
3205	app.initTabCoordinator(ctx)
3206
3207	err := app.switchBrowserWindowTabIndex(ctx, second, 3)
3208	if err == nil {
3209		t.Fatal("switchBrowserWindowTabIndex returned nil error, want config error")
3210	}
3211	if got := app.lastFocusedWindowID; got != "window-1" {
3212		t.Fatalf("lastFocusedWindowID = %q, want %q", got, "window-1")
3213	}
3214	if second.tabs != nil {
3215		t.Fatalf("second tabs should stay nil when new pane URL is missing")
3216	}
3217}
3218
3219func TestApp_WorkspaceOmniboxNavigateUsesOwnerWindow(t *testing.T) {
3220	gtkApp := requireGTKDisplayApp(t)
3221	defer gtkApp.Unref()
3222
3223	ctx := context.Background()
3224	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
3225	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
3226	firstTabs := entity.NewTabList()
3227	firstTabs.Add(tab1)
3228	firstTabs.SetActive(tab1.ID)
3229	secondTabs := entity.NewTabList()
3230	secondTabs.Add(tab2)
3231	secondTabs.SetActive(tab2.ID)
3232	first := &browserWindow{id: "window-1", tabs: firstTabs}
3233	second := &browserWindow{id: "window-2", tabs: secondTabs}
3234
3235	recordingWv1 := &recordingWebView{id: 1}
3236	recordingWv2 := &recordingWebView{id: 2}
3237	contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
3238	contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), recordingWv1)
3239	contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), recordingWv2)
3240
3241	app := &App{
3242		deps:                &Dependencies{},
3243		runtimeConfig:       runtimeConfigStateForTest(&config.Config{}),
3244		widgetFactory:       layout.NewGtkWidgetFactory(),
3245		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
3246		windowForTab:        map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
3247		lastFocusedWindowID: first.id,
3248		contentCoord:        contentCoord,
3249		navCoord:            coordinator.NewNavigationCoordinator(ctx, nil, contentCoord),
3250		workspaceViews:      map[entity.TabID]*component.WorkspaceView{},
3251	}
3252
3253	app.createWorkspaceViewWithoutAttach(ctx, tab2)
3254	wsView := app.workspaceViews[tab2.ID]
3255	if wsView == nil {
3256		t.Fatal("workspace view was not created for second tab")
3257	}
3258	wsView.ShowOmnibox(ctx, "")
3259	cb := omniboxNavigateCallbackForTest(t, wsView.GetOmnibox())
3260	if err := cb(ctx, "https://example.com"); err != nil {
3261		t.Fatalf("omnibox navigate callback returned error: %v", err)
3262	}
3263
3264	if recordingWv1.loadURICalled {
3265		t.Errorf("first window webview navigated to %q, want no navigation", recordingWv1.loadURILastURI)
3266	}
3267	if !recordingWv2.loadURICalled || recordingWv2.loadURILastURI != "https://example.com" {
3268		t.Errorf("second window webview navigation = called %v uri %q, want https://example.com", recordingWv2.loadURICalled, recordingWv2.loadURILastURI)
3269	}
3270}
3271
3272func TestApp_FloatingOmniboxNavigateUsesSessionPane(t *testing.T) {
3273	gtkApp := requireGTKDisplayApp(t)
3274	defer gtkApp.Unref()
3275
3276	ctx := context.Background()
3277	factory := layout.NewGtkWidgetFactory()
3278	overlay := factory.NewOverlay()
3279
3280	loadedURL := ""
3281	pane := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
3282		OnNavigate: func(_ context.Context, url string) error {
3283			loadedURL = url
3284			return nil
3285		},
3286	})
3287	session := &floatingWorkspaceSession{
3288		paneID:  entity.PaneID("floating-pane"),
3289		pane:    pane,
3290		overlay: overlay,
3291	}
3292	app := &App{widgetFactory: factory}
3293
3294	app.showFloatingOmnibox(ctx, session)
3295	cb := omniboxNavigateCallbackForTest(t, session.omnibox)
3296	if err := cb(ctx, "https://example.com"); err != nil {
3297		t.Fatalf("floating omnibox navigate callback returned error: %v", err)
3298	}
3299
3300	if loadedURL != "https://example.com" {
3301		t.Fatalf("floating pane loaded URL = %q, want https://example.com", loadedURL)
3302	}
3303}
3304
3305// TestApp_RestoreSessionDoesNotLeakStaleWindowsIntoTabMerge verifies that existing
3306// browserWindows not participating in the restore do not leak their tabs into the
3307// restored global tab list or UI aggregation. Only runtimeWindows (the windows
3308// actually restored) contribute.
3309func TestApp_RestoreSessionDoesNotLeakStaleWindowsIntoTabMerge(t *testing.T) {
3310	// Two stale browserWindows exist in the map; only one window is restored.
3311	staleBW1 := &browserWindow{id: "stale-w1"}
3312	staleBW2 := &browserWindow{id: "stale-w2"}
3313
3314	staleTab1 := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-ws"), entity.NewPane(entity.PaneID("stale-pane")))
3315	staleTab1.Name = "StaleTab"
3316	staleBW1.tabs = entity.NewTabList()
3317	staleBW1.tabs.Add(staleTab1)
3318
3319	staleBW2.tabs = entity.NewTabList()
3320
3321	// One restored window with its own tab. Tab names survive the
3322	// snapshot/restore cycle even though IDs are regenerated.
3323	restoredTabs := entity.NewTabList()
3324	restoredTab := entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-ws"), entity.NewPane(entity.PaneID("restored-pane")))
3325	restoredTab.Name = "RestoredTab"
3326	restoredTabs.Add(restoredTab)
3327
3328	sessionID := entity.SessionID("test-session")
3329	state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
3330		{WindowID: "saved-w1", Tabs: restoredTabs},
3331	}, 0, time.Unix(123, 0))
3332
3333	mainWindow := &window.MainWindow{}
3334	runtimeBW := &browserWindow{id: "runtime-w1", mainWindow: mainWindow}
3335
3336	app := &App{
3337		deps: &Dependencies{
3338			SessionStateRepo: mockSessionStateRepoWithSnapshot(t, sessionID, state),
3339		},
3340		runtimeConfig: runtimeConfigStateForTest(&config.Config{}),
3341		mainWindow:    mainWindow,
3342		browserWindows: map[string]*browserWindow{
3343			staleBW1.id:  staleBW1,
3344			staleBW2.id:  staleBW2,
3345			runtimeBW.id: runtimeBW,
3346		},
3347		tabs: entity.NewTabList(),
3348	}
3349	// Seed global tabs with a stale tab to verify it is replaced.
3350	app.tabs.Add(staleTab1)
3351
3352	if err := app.restoreSession(context.Background(), sessionID); err != nil {
3353		t.Fatalf("restoreSession returned error: %v", err)
3354	}
3355
3356	// Global tabs must only contain the restored tab, not stale tabs.
3357	if got := app.tabs.Count(); got != 1 {
3358		t.Fatalf("tabs.Count() = %d, want 1 (only restored tab)", got)
3359	}
3360	// Verify by name since IDs are regenerated during restore.
3361	restored := app.tabs.Tabs[0]
3362	if restored == nil || restored.Name != "RestoredTab" {
3363		t.Fatalf("restored tab name = %q, want RestoredTab", firstTabName(app.tabs))
3364	}
3365	if app.tabs.Find(staleTab1.ID) != nil {
3366		t.Fatal("stale tab leaked into global tabs")
3367	}
3368}
3369
3370func firstTabName(tl *entity.TabList) string {
3371	if len(tl.Tabs) == 0 || tl.Tabs[0] == nil {
3372		return ""
3373	}
3374	return tl.Tabs[0].Name
3375}
3376
3377// TestApp_RestoreSessionHonorsActiveWindowIndex verifies that when restoring a
3378// v2 multi-window session, the ActiveWindowIndex from the snapshot is used to
3379// determine the focused window and global active tab.
3380func TestApp_RestoreSessionHonorsActiveWindowIndex(t *testing.T) {
3381	gtkApp := requireGTKDisplayApp(t)
3382	defer gtkApp.Unref()
3383
3384	mainWindow, err := window.New(context.Background(), gtkApp, "top")
3385	if err != nil {
3386		t.Fatalf("window creation failed: %v", err)
3387	}
3388	defer mainWindow.Destroy()
3389
3390	// Window 0 has a tab named "Tab-W0", window 1 has "Tab-W1" (active).
3391	// Tab names survive snapshot/restore; IDs are regenerated.
3392	pane0 := entity.NewPane("pane-w0")
3393	tabW0 := entity.NewTab("tab-w0", "ws-w0", pane0)
3394	tabW0.Name = "Tab-W0"
3395	tabs0 := entity.NewTabList()
3396	tabs0.Add(tabW0)
3397
3398	pane1 := entity.NewPane("pane-w1")
3399	tabW1 := entity.NewTab("tab-w1", "ws-w1", pane1)
3400	tabW1.Name = "Tab-W1"
3401	tabs1 := entity.NewTabList()
3402	tabs1.Add(tabW1)
3403	tabs1.SetActive("tab-w1")
3404
3405	sessionID := entity.SessionID("test-sess")
3406	// ActiveWindowIndex = 1: second window should be focused.
3407	state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
3408		{WindowID: "saved-w0", Tabs: tabs0},
3409		{WindowID: "saved-w1", Tabs: tabs1},
3410	}, 1, time.Unix(123, 0))
3411
3412	firstBW := &browserWindow{id: "runtime-w0", mainWindow: mainWindow}
3413	// Factory creates the second runtime window; share the mainWindow so the UI
3414	// restore loop can walk its tab bar without needing a second GTK window.
3415	var runtimeW1 *browserWindow
3416	app := &App{
3417		deps: &Dependencies{
3418			SessionStateRepo: mockSessionStateRepoWithSnapshot(t, sessionID, state),
3419		},
3420		runtimeConfig:  runtimeConfigStateForTest(&config.Config{}),
3421		mainWindow:     mainWindow,
3422		widgetFactory:  layout.NewGtkWidgetFactory(),
3423		browserWindows: map[string]*browserWindow{firstBW.id: firstBW},
3424		tabs:           entity.NewTabList(),
3425		windowForTab:   map[entity.TabID]*browserWindow{},
3426		workspaceViews: map[entity.TabID]*component.WorkspaceView{},
3427		browserWindowFactory: func(ctx context.Context, url string) (*browserWindow, error) {
3428			runtimeW1 = &browserWindow{id: "runtime-w1", mainWindow: mainWindow}
3429			return runtimeW1, nil
3430		},
3431	}
3432
3433	if err := app.restoreSession(context.Background(), sessionID); err != nil {
3434		t.Fatalf("restoreSession returned error: %v", err)
3435	}
3436
3437	// lastFocusedWindowID must point to the active window (index 1).
3438	if app.lastFocusedWindowID != "runtime-w1" {
3439		t.Errorf("lastFocusedWindowID = %s, want runtime-w1 (active window index 1)", app.lastFocusedWindowID)
3440	}
3441
3442	// Global active tab must come from the active window (Tab-W1), not the first (Tab-W0).
3443	activeTab := app.tabs.ActiveTab()
3444	if activeTab == nil {
3445		t.Fatal("active tab is nil")
3446	}
3447	if activeTab.Name != "Tab-W1" {
3448		t.Errorf("active tab name = %q, want Tab-W1 (from active window)", activeTab.Name)
3449	}
3450}
3451
3452// TestApp_RestoreSessionFailsOnAdditionalWindowCreationError verifies that
3453// restoreSession returns an error (not silent partial restore) when creating
3454// a browser window for an additional restored window fails.
3455func TestApp_RestoreSessionFailsOnAdditionalWindowCreationError(t *testing.T) {
3456	gtkApp := requireGTKDisplayApp(t)
3457	defer gtkApp.Unref()
3458
3459	mainWindow, err := window.New(context.Background(), gtkApp, "top")
3460	if err != nil {
3461		t.Fatalf("window creation failed: %v", err)
3462	}
3463	defer mainWindow.Destroy()
3464
3465	pane0 := entity.NewPane("pane-w0")
3466	tab0 := entity.NewTab("tab-w0", "ws-w0", pane0)
3467	tabs0 := entity.NewTabList()
3468	tabs0.Add(tab0)
3469
3470	pane1 := entity.NewPane("pane-w1")
3471	tab1 := entity.NewTab("tab-w1", "ws-w1", pane1)
3472	tabs1 := entity.NewTabList()
3473	tabs1.Add(tab1)
3474
3475	sessionID := entity.SessionID("test-sess")
3476	state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
3477		{WindowID: "saved-w0", Tabs: tabs0},
3478		{WindowID: "saved-w1", Tabs: tabs1},
3479	}, 0, time.Unix(123, 0))
3480
3481	firstBW := &browserWindow{id: "runtime-w0", mainWindow: mainWindow}
3482	factoryErr := errors.New("window creation failed")
3483	app := &App{
3484		deps: &Dependencies{
3485			SessionStateRepo: mockSessionStateRepoWithSnapshot(t, sessionID, state),
3486		},
3487		runtimeConfig:  runtimeConfigStateForTest(&config.Config{}),
3488		mainWindow:     mainWindow,
3489		browserWindows: map[string]*browserWindow{firstBW.id: firstBW},
3490		tabs:           entity.NewTabList(),
3491		windowForTab:   map[entity.TabID]*browserWindow{},
3492		browserWindowFactory: func(ctx context.Context, url string) (*browserWindow, error) {
3493			return nil, factoryErr
3494		},
3495	}
3496
3497	gotErr := app.restoreSession(context.Background(), sessionID)
3498	if gotErr == nil {
3499		t.Fatal("restoreSession returned nil error, want failure when additional window creation fails")
3500	}
3501	if !strings.Contains(gotErr.Error(), "create browser window 1 for restore") {
3502		t.Errorf("error message should mention window creation for restore, got: %v", gotErr)
3503	}
3504	if !errors.Is(gotErr, factoryErr) {
3505		t.Errorf("error should wrap factoryErr, got: %v", gotErr)
3506	}
3507}
3508
3509func TestMainWindow_BottomTabBar_ContentAreaHasInset(t *testing.T) {
3510	gtkApp := requireGTKDisplayApp(t)
3511	defer gtkApp.Unref()
3512
3513	mw, err := window.New(context.Background(), gtkApp, "bottom")
3514	if err != nil {
3515		t.Fatalf("window creation failed: %v", err)
3516	}
3517	defer mw.Destroy()
3518
3519	// Bottom window should start WITHOUT the inset
3520	if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
3521		t.Fatal("bottom-tab-bar content area: should NOT have inset at window creation")
3522	}
3523
3524	// HasTabBarContentInset must return false initially
3525	if mw.HasTabBarContentInset() {
3526		t.Fatal("HasTabBarContentInset should return false initially")
3527	}
3528
3529	// SetTabBarContentInsetVisible(true) must add the class
3530	mw.SetTabBarContentInsetVisible(true)
3531	if !mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
3532		t.Fatal("bottom-tab-bar content area: expected content-area-tabbar-inset-bottom CSS class after SetTabBarContentInsetVisible(true)")
3533	}
3534	if !mw.HasTabBarContentInset() {
3535		t.Fatal("HasTabBarContentInset should return true after SetTabBarContentInsetVisible(true)")
3536	}
3537
3538	// SetTabBarContentInsetVisible(false) must remove the class
3539	mw.SetTabBarContentInsetVisible(false)
3540	if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
3541		t.Fatal("bottom-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(false)")
3542	}
3543	if mw.HasTabBarContentInset() {
3544		t.Fatal("HasTabBarContentInset should return false after SetTabBarContentInsetVisible(false)")
3545	}
3546
3547	// Verify tab bar remains a non-measured overlay regardless of inset state
3548	if mw.ContentOverlay().GetMeasureOverlay(mw.TabBar().Widget()) {
3549		t.Fatal("bottom-tab-bar: expected tab bar to remain a non-measured overlay")
3550	}
3551}
3552
3553func TestMainWindow_TopTabBar_ContentAreaHasTopInset(t *testing.T) {
3554	gtkApp := requireGTKDisplayApp(t)
3555	defer gtkApp.Unref()
3556
3557	mw, err := window.New(context.Background(), gtkApp, "top")
3558	if err != nil {
3559		t.Fatalf("window creation failed: %v", err)
3560	}
3561	defer mw.Destroy()
3562
3563	if mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") {
3564		t.Fatal("top-tab-bar content area: should NOT have inset at window creation")
3565	}
3566	if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
3567		t.Fatal("top-tab-bar content area: should NOT have content-area-tabbar-inset-bottom CSS class")
3568	}
3569
3570	mw.SetTabBarContentInsetVisible(true)
3571	if !mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") {
3572		t.Fatal("top-tab-bar content area: expected content-area-tabbar-inset-top CSS class after SetTabBarContentInsetVisible(true)")
3573	}
3574	if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
3575		t.Fatal("top-tab-bar content area: should NOT have bottom inset after SetTabBarContentInsetVisible(true)")
3576	}
3577	if !mw.HasTabBarContentInset() {
3578		t.Fatal("HasTabBarContentInset should return true for top inset")
3579	}
3580
3581	mw.SetTabBarContentInsetVisible(false)
3582	if mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") {
3583		t.Fatal("top-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(false)")
3584	}
3585	if mw.HasTabBarContentInset() {
3586		t.Fatal("HasTabBarContentInset should return false after removing top inset")
3587	}
3588
3589	if mw.ContentOverlay().GetMeasureOverlay(mw.TabBar().Widget()) {
3590		t.Fatal("top-tab-bar: expected tab bar to remain a non-measured overlay")
3591	}
3592}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close