v0.30.0 eb3068f

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

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