diff --git a/cursed_renderer.go b/cursed_renderer.go index 0ad3e602b2..4d77b76787 100644 --- a/cursed_renderer.go +++ b/cursed_renderer.go @@ -291,6 +291,12 @@ func (s *cursedRenderer) flush(closing bool) error { s.mu.Lock() defer s.mu.Unlock() + return s.flushLocked(closing) +} + +// flushLocked is also used by insertAbove, which holds the lock for the entire +// flush/insert/restore transaction. +func (s *cursedRenderer) flushLocked(closing bool) error { view := s.view frameArea := uv.Rect(0, 0, s.width, s.height) if len(view.Content) == 0 { @@ -761,54 +767,82 @@ func (s *cursedRenderer) insertAbove(str string) error { if len(str) == 0 { return nil } - - var sb strings.Builder - w, h := s.cellbuf.Width(), s.cellbuf.Height() - _, y := s.scr.Position() - - // We need to scroll the screen up by the number of lines in the queue. - sb.WriteByte('\r') - down := h - y - 1 - if down > 0 { - sb.WriteString(ansi.CursorDown(down)) + // Measure the physical frame after flushing any pending View or resize. + if err := s.flushLocked(false); err != nil { + return err + } + if s.view.AltScreen { + return nil } - lines := strings.Split(str, "\n") - offset := len(lines) - for _, line := range lines { - lineWidth := ansi.StringWidth(line) - if w > 0 && lineWidth > w { - offset += (lineWidth / w) + lines := strings.Split(s.cellbuf.Method.Hardwrap(str, max(1, s.width), true), "\n") + h := s.cellbuf.Height() + budget := s.height - h + if h == 0 || budget <= 0 { + // Without room for reservations, release only the managed frame and + // let ordinary terminal scrolling append the text to native history. + // Rebase at the next line and repaint the frame below the inserted text. + s.scr.MoveTo(0, 0) + _, _ = s.scr.WriteString(ansi.EraseScreenBelow) + if err := s.scr.Flush(); err != nil { + return fmt.Errorf("bubbletea: error releasing inline frame: %w", err) + } + if _, err := io.Copy(s.w, &s.buf); err != nil { + return fmt.Errorf("bubbletea: error writing inline handoff: %w", err) + } + for _, line := range lines { + if err := s.writeInsert(line + "\r\n"); err != nil { + return err + } } + s.scr.SetPosition(0, 0) + s.scr.Erase() + s.pendingErase = true + return s.flushLocked(false) } - // Scroll the screen up by the offset to make room for the new lines. - sb.WriteString(strings.Repeat("\n", offset)) - - // XXX: Now go to the top of the screen, insert new lines, and write - // the queued strings. It is important to use [Screen.moveCursor] - // instead of [Screen.move] because we don't want to perform any checks - // on the cursor position. - up := offset + h - 1 - sb.WriteString(ansi.CursorUp(up)) - sb.WriteString(ansi.InsertLine(offset)) - for _, line := range lines { - sb.WriteString(line) - sb.WriteString(ansi.EraseLineRight) - sb.WriteString("\r\n") + // Reserving more rows than fit above the frame would scroll the frame + // itself away. Insert in groups bounded by the available physical rows. + for start := 0; start < len(lines); start += budget { + end := min(len(lines), start+budget) + var sb strings.Builder + _, y := s.scr.Position() + sb.WriteByte('\r') + if down := h - y - 1; down > 0 { + sb.WriteString(ansi.CursorDown(down)) + } + offset := end - start + sb.WriteString(strings.Repeat("\n", offset)) + sb.WriteString(ansi.CursorUp(offset + h - 1)) + sb.WriteString(ansi.InsertLine(offset)) + for _, line := range lines[start:end] { + sb.WriteString(line) + // CR cancels delayed wrap at the exact right margin. + sb.WriteString("\r\n") + } + if err := s.writeInsert(sb.String()); err != nil { + return err + } + s.scr.SetPosition(0, 0) } - s.scr.SetPosition(0, 0) + // Insertion changed the terminal cursor even if the View is identical. + // Bypass the equality fast path to restore the View and cursor now. + s.pendingErase = true + return s.flushLocked(false) +} +func (s *cursedRenderer) writeInsert(str string) error { if s.logger != nil { - s.logger.Printf("insert above: %q", sb.String()) + s.logger.Printf("insert above: %q", str) + } + n, err := io.WriteString(s.w, str) + if err == nil && n != len(str) { + err = io.ErrShortWrite } - - _, err := io.WriteString(s.w, sb.String()) if err != nil { return fmt.Errorf("bubbletea: error writing insert above to the writer: %w", err) } - return nil } diff --git a/cursed_renderer_test.go b/cursed_renderer_test.go index 8b2efa8d4f..b4c8cdcbfd 100644 --- a/cursed_renderer_test.go +++ b/cursed_renderer_test.go @@ -2,13 +2,16 @@ package tea import ( "bytes" + "errors" "fmt" "io" "strings" "testing" "time" + "github.com/charmbracelet/colorprofile" "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/vt" ) type mouseRaceModel struct { @@ -83,6 +86,319 @@ func TestCursedRenderer_mouseVsFlush(t *testing.T) { } } +// Fixes: https://github.com/charmbracelet/bubbletea/issues/1822 +func TestCursedRenderer_insertAboveMultiScreen(t *testing.T) { + t.Parallel() + + const width, height = 40, 10 + e := vt.NewEmulator(width, height) + defer e.Close() + visible := true + e.SetCallbacks(vt.Callbacks{CursorVisibility: func(v bool) { visible = v }}) + if _, err := e.Write([]byte("SHELL-HISTORY\r\n")); err != nil { + t.Fatal(err) + } + r := newCursedRenderer(e, []string{"TERM=xterm-256color"}, width, height) + r.setNoInput(true) + view := NewView("CONTROLS\n> input\nSTATUS") + view.Cursor = NewCursor(2, 1) + r.render(view) + if err := r.flush(false); err != nil { + t.Fatal(err) + } + if !strings.Contains(e.String(), view.Content) { + t.Fatalf("initial View was not painted: %q", e.String()) + } + + lines := make([]string, 80) + for i := range lines { + lines[i] = fmt.Sprintf("ROW-%03d", i) + } + // No pending View, resize, subsequent flush, or cleanup may repair the + // screen before these assertions. This isolates the multi-screen case + // from inserting before the first flush or after a changed View. + if err := r.insertAbove(strings.Join(lines, "\n")); err != nil { + t.Fatal(err) + } + assertTerminalLines(t, e, append([]string{"SHELL-HISTORY"}, lines...)) + if e.ScrollbackLen() == 0 || e.IsAltScreen() { + t.Error("insertion did not preserve native scrollback") + } + screen := e.String() + if !strings.Contains(screen, view.Content) { + t.Errorf("managed View missing immediately after insertion: %q", screen) + } + pos := e.CursorPosition() + if !visible || pos.X != 2 || strings.Split(screen, "\n")[pos.Y] != "> input" { + t.Errorf("cursor not restored: visible=%v position=%+v screen=%q", visible, pos, screen) + } +} + +func TestCursedRenderer_insertAbovePendingView(t *testing.T) { + t.Parallel() + + for _, size := range [][2]int{{40, 10}, {80, 24}, {120, 40}, {12, 3}} { + for _, full := range []bool{false, true} { + for _, hidden := range []bool{false, true} { + t.Run(fmt.Sprintf("%dx%d/full=%v/hidden=%v", size[0], size[1], full, hidden), func(t *testing.T) { + e := vt.NewEmulator(size[0], size[1]) + defer e.Close() + visible := true + e.SetCallbacks(vt.Callbacks{CursorVisibility: func(v bool) { visible = v }}) + if _, err := e.Write([]byte("SHELL\r\n")); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + r := newCursedRenderer(io.MultiWriter(&output, e), []string{"TERM=xterm-256color"}, 80, 24) + r.setNoInput(true) + r.resize(size[0], size[1]) + frame := "CONTROLS\n> input\nSTATUS" + if full { + frame = strings.Repeat("managed\n", size[1]-3) + frame + } + view := NewView(frame) + if !hidden { + view.Cursor = NewCursor(2, strings.Count(frame, "\n")-1) + } + r.render(view) // The first View and resize have not been flushed. + lines := make([]string, 80) + for i := range lines { + lines[i] = fmt.Sprintf("ROW-%03d", i) + } + if err := r.insertAbove(strings.Join(lines, "\n")); err != nil { + t.Fatal(err) + } + assertTerminalLines(t, e, append([]string{"SHELL"}, lines...)) + screen := e.String() + if !strings.Contains(screen, frame) { + t.Fatalf("missing View immediately after insertion: %q", screen) + } + if visible == hidden { + t.Errorf("cursor visibility = %v, want %v", visible, !hidden) + } + if !hidden { + pos := e.CursorPosition() + if pos.X != 2 || strings.Split(screen, "\n")[pos.Y] != "> input" { + t.Errorf("cursor not restored: %+v screen=%q", pos, screen) + } + } + if e.IsAltScreen() || strings.Contains(output.String(), "\x1b[3J") { + t.Error("insertion changed native history ownership") + } + before := terminalHistory(e) + if err := r.flush(false); err != nil { + t.Fatal(err) + } + if before != terminalHistory(e) { + t.Error("identical subsequent View changed history") + } + }) + } + } + } +} + +func TestCursedRenderer_insertAbovePendingShrink(t *testing.T) { + t.Parallel() + + e := vt.NewEmulator(40, 10) + defer e.Close() + if _, err := e.Write([]byte("SHELL\r\n")); err != nil { + t.Fatal(err) + } + r := newCursedRenderer(e, []string{"TERM=xterm-256color"}, 40, 10) + r.setNoInput(true) + r.setColorProfile(colorprofile.TrueColor) + r.render(NewView(strings.Repeat("old frame\n", 7) + "old footer")) + if err := r.flush(false); err != nil { + t.Fatal(err) + } + view := NewView("CONTROLS\n> input\nSTATUS") + view.Cursor = NewCursor(2, 1) + r.render(view) + exact := strings.Repeat("界", 20) + payload := "\x1b[31m" + exact + exact + "\x1b[0m\nemoji 👋\nlast" + if err := r.insertAbove(payload); err != nil { + t.Fatal(err) + } + history := terminalHistory(e) + if strings.Count(history, exact) != 2 { + t.Fatalf("lost styled/wide/exact-margin content: %q", history) + } + assertTerminalLines(t, e, []string{"SHELL", "emoji 👋", "last"}) + if strings.Contains(history, "old frame") || strings.Contains(history, "old footer") { + t.Errorf("pending shrink left old content in history: %q", history) + } + screen := e.String() + pos := e.CursorPosition() + if !strings.Contains(screen, view.Content) || pos.X != 2 || strings.Split(screen, "\n")[pos.Y] != "> input" { + t.Errorf("View or cursor not restored: %+v screen=%q", pos, screen) + } +} + +func TestCursedRenderer_insertAboveFollowingFrame(t *testing.T) { + t.Parallel() + + for _, size := range [][2]int{{1, 1}, {2, 2}, {12, 3}, {40, 10}} { + for _, frameHeight := range []int{0, 1, size[1], size[1] + 3} { + t.Run(fmt.Sprintf("%dx%d/frame=%d", size[0], size[1], frameHeight), func(t *testing.T) { + e := vt.NewEmulator(size[0], size[1]) + defer e.Close() + r := newCursedRenderer(e, []string{"TERM=xterm-256color"}, size[0], size[1]) + r.setNoInput(true) + frame := strings.TrimSuffix(strings.Repeat("M\n", frameHeight), "\n") + r.render(NewView(frame)) + lines := []string{"A", "B", "C", "D", "E", "F"} + if err := r.insertAbove(strings.Join(lines, "\n")); err != nil { + t.Fatal(err) + } + assertTerminalLines(t, e, lines) + r.render(NewView(strings.ReplaceAll(frame, "M", "N"))) + if err := r.flush(false); err != nil { + t.Fatal(err) + } + if err := r.insertAbove("G"); err != nil { + t.Fatal(err) + } + assertTerminalLines(t, e, append(lines, "G")) + screen := e.String() + if strings.Contains(screen, "M") || strings.Count(screen, "N") != min(frameHeight, size[1]) { + t.Errorf("subsequent View not restored: %q", screen) + } + }) + } + } +} + +func TestCursedRenderer_insertAboveAltScreen(t *testing.T) { + t.Parallel() + + e := vt.NewEmulator(40, 10) + defer e.Close() + r := newCursedRenderer(e, []string{"TERM=xterm-256color"}, 40, 10) + r.setNoInput(true) + view := NewView("alternate") + view.AltScreen = true + r.render(view) + if err := r.insertAbove("must not print"); err != nil { + t.Fatal(err) + } + if !e.IsAltScreen() || !strings.Contains(e.String(), "alternate") || strings.Contains(e.String(), "must not print") { + t.Fatalf("pending alternate screen not preserved: %q", e.String()) + } + before := e.String() + if err := r.insertAbove("must not print either"); err != nil { + t.Fatal(err) + } + if before != e.String() { + t.Error("insertion changed alternate screen") + } + r.render(NewView("inline")) + if err := r.insertAbove("printed"); err != nil { + t.Fatal(err) + } + assertTerminalLines(t, e, []string{"printed", "inline"}) + if e.IsAltScreen() { + t.Error("did not leave alternate screen") + } +} + +func TestCursedRenderer_insertAboveWriterErrors(t *testing.T) { + t.Parallel() + + failure := errors.New("terminal disconnected") + for _, tt := range []struct { + name string + pending bool + full bool + failAt int + }{ + {name: "initial flush", pending: true, failAt: 1}, + {name: "bounded insertion", failAt: 1}, + {name: "cursor restoration", failAt: 2}, + {name: "release full frame", full: true, failAt: 1}, + {name: "append after release", full: true, failAt: 2}, + {name: "repaint full frame", full: true, failAt: 4}, + } { + for _, short := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/short=%v", tt.name, short), func(t *testing.T) { + r := newCursedRenderer(io.Discard, []string{"TERM=xterm-256color"}, 40, 10) + r.setNoInput(true) + frame := "CONTROLS\n> input\nSTATUS" + if tt.full { + frame = strings.Repeat("managed\n", 7) + frame + } + view := NewView(frame) + view.Cursor = NewCursor(2, strings.Count(frame, "\n")-1) + r.render(view) + if !tt.pending { + if err := r.flush(false); err != nil { + t.Fatal(err) + } + } + r.w = &insertErrorWriter{failAt: tt.failAt, short: short, err: failure} + want := failure + if short { + want = io.ErrShortWrite + } + if err := r.insertAbove("first\nsecond"); !errors.Is(err, want) { + t.Errorf("insertion error = %v, want %v", err, want) + } + }) + } + } +} + +type insertErrorWriter struct { + failAt int + writes int + short bool + err error +} + +func (w *insertErrorWriter) Write(p []byte) (int, error) { + w.writes++ + if w.writes == w.failAt { + if w.short { + return len(p) / 2, nil + } + return 0, w.err + } + return len(p), nil +} + +func terminalHistory(e *vt.Emulator) string { + var b strings.Builder + for _, line := range e.Scrollback().Lines() { + for _, cell := range line { + b.WriteString(cell.Content) + } + b.WriteByte('\n') + } + b.WriteString(e.String()) + return b.String() +} + +func assertTerminalLines(t *testing.T, e *vt.Emulator, wants []string) { + t.Helper() + history := terminalHistory(e) + lines := strings.Split(history, "\n") + previous := -1 + for _, want := range wants { + count, at := 0, -1 + for i, line := range lines { + if strings.TrimRight(line, " ") == want { + count++ + at = i + } + } + if count != 1 || at <= previous { + t.Fatalf("missing, duplicate, or out-of-order %q: %q", want, history) + } + previous = at + } +} + func assertInOrder(t *testing.T, got string, wants ...string) { t.Helper() rest := got diff --git a/examples/go.sum b/examples/go.sum index 0fe8c5fdd1..84c1f78f33 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -30,12 +30,16 @@ github.com/charmbracelet/x/exp/charmtone v0.0.0-20250602192518-9e722df69bbb h1:o github.com/charmbracelet/x/exp/charmtone v0.0.0-20250602192518-9e722df69bbb/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/vt v0.0.0-20260927004216-9c77d672503d h1:4JMIalS3HI866QnQkTaF6cRM6e4CV0J7etYLxBaJqnc= +github.com/charmbracelet/x/vt v0.0.0-20260927004216-9c77d672503d/go.mod h1:u1LOIABor9JqY54oZdktK3TCRrgzP6tzHrDYx1nd3wY= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= diff --git a/go.mod b/go.mod index 84e3188f1e..3b004a91df 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/charmbracelet/x/ansi v0.11.7 github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f github.com/charmbracelet/x/term v0.2.2 + github.com/charmbracelet/x/vt v0.0.0-20260927004216-9c77d672503d github.com/lucasb-eyer/go-colorful v1.4.0 github.com/muesli/cancelreader v0.2.2 golang.org/x/sys v0.46.0 @@ -17,6 +18,7 @@ require ( require ( github.com/aymanbagabas/go-udiff v0.2.0 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect diff --git a/go.sum b/go.sum index 368d3945ee..03cd24829f 100644 --- a/go.sum +++ b/go.sum @@ -8,10 +8,14 @@ github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dA github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w= github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/vt v0.0.0-20260927004216-9c77d672503d h1:4JMIalS3HI866QnQkTaF6cRM6e4CV0J7etYLxBaJqnc= +github.com/charmbracelet/x/vt v0.0.0-20260927004216-9c77d672503d/go.mod h1:u1LOIABor9JqY54oZdktK3TCRrgzP6tzHrDYx1nd3wY= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= @@ -28,8 +32,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=