diff --git a/pkg/commands/git_commands/file_loader.go b/pkg/commands/git_commands/file_loader.go index 3f960cb30..747572b38 100644 --- a/pkg/commands/git_commands/file_loader.go +++ b/pkg/commands/git_commands/file_loader.go @@ -2,7 +2,6 @@ package git_commands import ( "fmt" - "path/filepath" "strconv" "strings" @@ -91,26 +90,6 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File self.setConflictMarkerSizes(files) - // Go through the files to see if any of these files are actually worktrees - // so that we can render them correctly - worktreePaths := linkedWortkreePaths(self.Fs, self.repoPaths.RepoGitDirPath()) - for _, file := range files { - for _, worktreePath := range worktreePaths { - absFilePath, err := filepath.Abs(file.Path) - if err != nil { - self.Log.Error(err) - continue - } - if absFilePath == worktreePath { - file.IsWorktree = true - // `git status` renders this worktree as a folder with a trailing slash but we'll represent it as a singular worktree - // If we include the slash, it will be rendered as a folder with a null file inside. - file.Path = strings.TrimSuffix(file.Path, "/") - break - } - } - } - return files } diff --git a/pkg/commands/git_commands/repo_paths.go b/pkg/commands/git_commands/repo_paths.go index 61afc943f..0473f8f8e 100644 --- a/pkg/commands/git_commands/repo_paths.go +++ b/pkg/commands/git_commands/repo_paths.go @@ -1,7 +1,6 @@ package git_commands import ( - ioFs "io/fs" "os" "path/filepath" "strings" @@ -10,7 +9,6 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/oscommands" "github.com/jesseduffield/lazygit/pkg/env" "github.com/jesseduffield/lazygit/pkg/utils" - "github.com/spf13/afero" ) type RepoPaths struct { @@ -302,41 +300,3 @@ func runGitRevParse(gitCmd *oscommands.CmdObj) (string, error) { } return strings.TrimSpace(res), nil } - -// Returns the paths of linked worktrees -func linkedWortkreePaths(fs afero.Fs, repoGitDirPath string) []string { - result := []string{} - // For each directory in this path we're going to cat the `gitdir` file and append its contents to our result - // That file points us to the `.git` file in the worktree. - worktreeGitDirsPath := filepath.Join(repoGitDirPath, "worktrees") - - // ensure the directory exists - _, err := fs.Stat(worktreeGitDirsPath) - if err != nil { - return result - } - - _ = afero.Walk(fs, worktreeGitDirsPath, func(currPath string, info ioFs.FileInfo, err error) error { - if err != nil { - return err - } - - if !info.IsDir() { - return nil - } - - gitDirPath := filepath.Join(currPath, "gitdir") - gitDirBytes, err := afero.ReadFile(fs, gitDirPath) - if err != nil { - // ignoring error - return nil - } - trimmedGitDir := strings.TrimSpace(string(gitDirBytes)) - // removing the .git part - worktreeDir := filepath.Dir(trimmedGitDir) - result = append(result, worktreeDir) - return nil - }) - - return result -} diff --git a/pkg/gocui/gui.go b/pkg/gocui/gui.go index a9b68dc5c..833a0af81 100644 --- a/pkg/gocui/gui.go +++ b/pkg/gocui/gui.go @@ -222,6 +222,11 @@ type Gui struct { // worker goroutines, so it's atomic. uiThreadID atomic.Int64 + // focused says whether the terminal we're running in has focus, as far as + // its focus reports tell us (see IsFocused). Written by the event loop, + // readable from anywhere, so it's atomic. + focused atomic.Bool + // blockInputCount, when greater than zero, withholds keyboard input from // the handlers: key events are buffered into bufferedKeyEvents and replayed // once the count drops back to zero, while mouse clicks and hover are @@ -306,6 +311,12 @@ func NewGui(opts NewGuiOpts) (*Gui, error) { // runs during startup, before we reach MainLoop. g.uiThreadID.Store(goid.Get()) + // Assume we start out focused: a terminal that supports focus reports sends + // one for the state it is already in when we turn reporting on in MainLoop, + // and passing that on as a change would have the app react to a change that + // never happened. + g.focused.Store(true) + return g, nil } @@ -2009,7 +2020,21 @@ func (g *Gui) execKeybinding(v *View, kb *keybinding) error { return nil } +// IsFocused reports whether the terminal we're running in has focus. Terminals +// that don't report focus at all leave this true for good. +func (g *Gui) IsFocused() bool { + return g.focused.Load() +} + func (g *Gui) onFocus(ev *GocuiEvent) error { + // Terminals report their focus state when we turn focus reporting on, and + // some report it again when their window is activated, so only pass on the + // reports that actually change it. + if ev.Focused == g.focused.Load() { + return nil + } + g.focused.Store(ev.Focused) + if g.focusHandler != nil { return g.focusHandler(ev.Focused) } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index d076ced0a..40ce86eae 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -273,6 +273,10 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // - merge conflicts are part of what the files refresh produces // - pull requests are fetched for the tracking branches against the // remotes, so refresh both alongside to fetch against fresh data + // - commits and branches always go together: changing commits changes + // the branches' upstream/downstream counts, and changing branches + // (e.g. checking one out) changes the commits we show. This one comes + // last, so that it also covers the branches the rules above add. if scopeSet.Includes(types.REFLOG) || scopeSet.Includes(types.BISECT_INFO) { scopeSet.Add(types.COMMITS, types.BRANCHES) } @@ -285,6 +289,9 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr if scopeSet.Includes(types.PULL_REQUESTS) { scopeSet.Add(types.BRANCHES, types.REMOTES) } + if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) { + scopeSet.Add(types.COMMITS, types.BRANCHES) + } // Capture the refs snapshot now, before we start reading git's state // below, rather than after. This is important to guard against the race @@ -311,6 +318,23 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr }) } + // The branches view shows worktrees against branches, so a branches render + // that happens before the refreshed worktrees have landed in the model shows + // stale ones, and rendering again once they land makes the view flicker. + // Refresh the worktrees first, then, and let the branches refresh wait for + // them: waitForWorktrees returns once the worktrees model write is queued, + // so the branches write that follows is queued behind it and the view + // renders once, with both. + worktreesWg := sync.WaitGroup{} + waitForWorktrees := func() { worktreesWg.Wait() } + if scopeSet.Includes(types.WORKTREES) { + worktreesWg.Add(1) + refresh("worktrees", func() { + defer worktreesWg.Done() + self.refreshWorktrees(env, scopeSet.Includes(types.BRANCHES)) + }) + } + branchesAndRemotesWg := sync.WaitGroup{} // The pull-request fetch (below) needs the just-loaded branches and // remotes. Their model writes are bounced onto the UI thread, so the @@ -320,52 +344,23 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr // branchesAndRemotesWg gives the fetch the happens-before to read them. var loadedBranches []*models.Branch var loadedRemotes []*models.Remote - includeWorktreesWithBranches := false - if scopeSet.Includes(types.COMMITS) || scopeSet.Includes(types.BRANCHES) { - // whenever we change commits, we should update branches because the upstream/downstream - // counts can change. Whenever we change branches we should also change commits - // e.g. in the case of switching branches. - // Capture the commits, reflog and branches refresh inputs (model, - // contexts, modes) on the UI thread, before the git work is dispatched - // to a worker, so the workers compute from an immutable snapshot - // instead of reading state the UI thread concurrently mutates. + if scopeSet.Includes(types.COMMITS) { + // Capture the refresh's inputs (model, contexts, modes) on the UI + // thread, before the git work is dispatched to a worker, so the worker + // computes from an immutable snapshot instead of reading state the UI + // thread concurrently mutates. Every scope below does the same. var capturedCommits capturedCommitState - var capturedReflog capturedReflogState - var capturedBranches capturedBranchState if !self.captureOnUIThread(calledFromWorker, env.background, func() { capturedCommits = self.captureCommitsState() - capturedReflog = self.captureReflogState() - capturedBranches = self.captureBranchState() }) { return } refresh("commits and commit files", func() { self.refreshCommitsAndCommitFiles(capturedCommits, options.CommitSelection, env) }) - - includeWorktreesWithBranches = scopeSet.Includes(types.WORKTREES) - if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { - branchesAndRemotesWg.Add(1) - refresh("reflog and branches", func() { - loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, includeWorktreesWithBranches, options.BranchSelection, options.SelectTopReflogCommit, env) - branchesAndRemotesWg.Done() - }) - } else { - branchesAndRemotesWg.Add(1) - refresh("branches", func() { - // Not a recency sort, so branches doesn't depend on the reflog - // being fresh; it runs concurrently with the reflog refresh - // below and uses the reflog we captured up front, as it always has. - loadedBranches = self.refreshBranches(capturedBranches, includeWorktreesWithBranches, options.BranchSelection, true, capturedReflog.reflogCommits, env) - branchesAndRemotesWg.Done() - }) - refresh("reflog", func() { - _, _ = self.refreshReflogCommits(capturedReflog, env, options.SelectTopReflogCommit) - }) - } } else if scopeSet.Includes(types.REBASE_COMMITS) { - // the above block handles rebase commits so we only need to call this one - // if we've asked specifically for rebase commits and not those other things + // the commits refresh above loads the rebase commits as well, so we only + // need this one when the rebase commits are all that was asked for var rebaseHashPool *utils.StringPool var rebaseCommits []*models.Commit if !self.captureOnUIThread(calledFromWorker, env.background, func() { @@ -376,6 +371,39 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr refresh("rebase commits", func() { _ = self.refreshRebaseCommits(rebaseHashPool, rebaseCommits, env) }) } + if scopeSet.Includes(types.BRANCHES) { + // The reflog is refreshed here rather than in a scope of its own, + // because sorting the branches by recency needs it to be loaded first. + var capturedReflog capturedReflogState + var capturedBranches capturedBranchState + if !self.captureOnUIThread(calledFromWorker, env.background, func() { + capturedReflog = self.captureReflogState() + capturedBranches = self.captureBranchState() + }) { + return + } + + if self.c.UserConfig().Git.LocalBranchSortOrder == "recency" { + branchesAndRemotesWg.Add(1) + refresh("reflog and branches", func() { + loadedBranches = self.refreshReflogAndBranches(capturedReflog, capturedBranches, waitForWorktrees, options.BranchSelection, options.SelectTopReflogCommit, env) + branchesAndRemotesWg.Done() + }) + } else { + branchesAndRemotesWg.Add(1) + refresh("branches", func() { + // Not a recency sort, so branches doesn't depend on the reflog + // being fresh; it runs concurrently with the reflog refresh + // below and uses the reflog we captured up front, as it always has. + loadedBranches = self.refreshBranches(capturedBranches, waitForWorktrees, options.BranchSelection, true, capturedReflog.reflogCommits, env) + branchesAndRemotesWg.Done() + }) + refresh("reflog", func() { + _, _ = self.refreshReflogCommits(capturedReflog, env, options.SelectTopReflogCommit) + }) + } + } + if scopeSet.Includes(types.SUB_COMMITS) { var capturedSubCommits capturedSubCommitState if !self.captureOnUIThread(calledFromWorker, env.background, func() { @@ -468,10 +496,6 @@ func (self *RefreshHelper) performRefresh(options types.RefreshOptions, calledFr }) } - if scopeSet.Includes(types.WORKTREES) && !includeWorktreesWithBranches { - refresh("worktrees", func() { self.refreshWorktrees(env) }) - } - if scopeSet.Includes(types.STAGING) { refresh("staging", func() { fileWg.Wait() @@ -698,17 +722,19 @@ func (self *RefreshHelper) captureBranchState() capturedBranchState { } } -func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch { +func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflogState, capturedBranches capturedBranchState, waitForWorktrees func(), branchSelection types.BranchSelectionBehavior, selectTopReflogCommit bool, env refreshEnv) []*models.Branch { switch self.c.State().GetRepoState().GetStartupStage() { case types.INITIAL: // Return the immediate (non-recency) load's branches; the recency-sorted // reload below runs on its own worker after we return. Both hold the same // set of branches, which is all the caller (the PR fetch) needs. - branches := self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, false, capturedReflog.reflogCommits, env) + branches := self.refreshBranches(capturedBranches, waitForWorktrees, branchSelection, false, capturedReflog.reflogCommits, env) self.onWorker(env.background, func(_ gocui.Task) error { reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, false) - self.refreshBranches(capturedBranches, false, types.SelectCheckedOutBranch, true, reflogCommits, env) + // The load above already waited for the worktrees, so this one has + // nothing left to wait for. + self.refreshBranches(capturedBranches, func() {}, types.SelectCheckedOutBranch, true, reflogCommits, env) self.c.State().GetRepoState().SetStartupStage(types.COMPLETE) return nil }) @@ -717,7 +743,7 @@ func (self *RefreshHelper) refreshReflogAndBranches(capturedReflog capturedReflo case types.COMPLETE: reflogCommits, _ := self.refreshReflogCommits(capturedReflog, env, selectTopReflogCommit) - return self.refreshBranches(capturedBranches, refreshWorktrees, branchSelection, true, reflogCommits, env) + return self.refreshBranches(capturedBranches, waitForWorktrees, branchSelection, true, reflogCommits, env) } return nil @@ -1082,7 +1108,7 @@ func (self *RefreshHelper) refreshStateSubmoduleConfigs(env refreshEnv) ([]*mode // self.refreshStatus is called at the end of this because that's when we can // be sure there is a State.Model.Branches array to pick the current branch from -func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refreshWorktrees bool, branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch { +func (self *RefreshHelper) refreshBranches(captured capturedBranchState, waitForWorktrees func(), branchSelection types.BranchSelectionBehavior, loadBehindCounts bool, reflogCommits []*models.Commit, env refreshEnv) []*models.Branch { loadSeq := self.branchLoadSeq.Add(1) branches, err := env.git.Loaders.BranchLoader.Load( @@ -1116,10 +1142,9 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh self.c.Log.Error(err) } - var worktrees []*models.Worktree - if refreshWorktrees { - worktrees = self.loadWorktrees(env) - } + // Render only once the refreshed worktrees are in the model; the branches + // view shows them against the branches (see performRefresh). + waitForWorktrees() self.onUIThreadUnlessRepoChanged(env, func() { // Drop this write if a branch load that started later has already applied @@ -1142,11 +1167,6 @@ func (self *RefreshHelper) refreshBranches(captured capturedBranchState, refresh // the branches we just wrote, on the UI thread. self.rebuildPullRequestsMap() - if refreshWorktrees { - self.c.Model().Worktrees = worktrees - self.refreshView(self.c.Contexts().Worktrees, env) - } - // Setting the selection here, in the same bounce that writes the list, // keeps it on the UI thread and keeps the list and selection updating in // the same frame. @@ -1399,12 +1419,45 @@ func (self *RefreshHelper) refreshStateFiles(captured capturedFilesState, env re self.c.Model().Submodules = submoduleConfigs self.c.Model().Files = files + markWorktreeFiles(files, self.c.Model().Worktrees, env.git.RepoPaths.WorktreePath()) fileTreeViewModel.SetTree() }) return nil } +// markWorktreeFiles marks the files that are linked worktrees of this repo, so +// that the files view can render them as such. `git status` reports a worktree +// as an untracked directory, i.e. with a trailing slash, which we take off: +// keeping it would build a directory node with a nameless file inside it. +// +// It must run on the UI thread, as it works on the model. Both models it needs +// are written by refreshes of their own, so it is called after either of them +// lands; it reports whether it changed anything. +func markWorktreeFiles(files []*models.File, worktrees []*models.Worktree, worktreePath string) bool { + changed := false + + for _, file := range files { + absPath := filepath.Join(worktreePath, file.Path) + isWorktree := lo.SomeBy(worktrees, func(worktree *models.Worktree) bool { + return worktree.Path == absPath + }) + + if isWorktree != file.IsWorktree { + file.IsWorktree = isWorktree + changed = true + } + if isWorktree { + if trimmed := strings.TrimSuffix(file.Path, "/"); trimmed != file.Path { + file.Path = trimmed + changed = true + } + } + } + + return changed +} + // the reflogs panel is the only panel where we cache data, in that we only // load entries that have been created since we last ran the call. This means // we need to be more careful with how we use this, and to ensure we're emptying @@ -1506,16 +1559,27 @@ func (self *RefreshHelper) loadWorktrees(env refreshEnv) []*models.Worktree { return worktrees } -func (self *RefreshHelper) refreshWorktrees(env refreshEnv) { +func (self *RefreshHelper) refreshWorktrees(env refreshEnv, branchesAreRefreshing bool) { worktrees := self.loadWorktrees(env) self.onUIThreadUnlessRepoChanged(env, func() { self.c.Model().Worktrees = worktrees + + // A worktree inside our working tree is one of the files, so the files + // view has to be told about the ones we just loaded (see + // markWorktreeFiles). Rebuild the tree because a file's path can change. + if markWorktreeFiles(self.c.Model().Files, worktrees, env.git.RepoPaths.WorktreePath()) { + self.c.Contexts().Files.FileTreeViewModel.SetTree() + self.refreshView(self.c.Contexts().Files, env) + } }) - // need to refresh branches because the branches view shows worktrees against - // branches - self.refreshView(self.c.Contexts().Branches, env) + // The branches view shows worktrees against branches, so it needs to be + // rendered again as well. When the branches are being refreshed too, they + // render after waiting for the write above, so leave it to them. + if !branchesAreRefreshing { + self.refreshView(self.c.Contexts().Branches, env) + } self.refreshView(self.c.Contexts().Worktrees, env) } diff --git a/pkg/gui/controllers/helpers/refresh_helper_test.go b/pkg/gui/controllers/helpers/refresh_helper_test.go index d3829127a..b92a64285 100644 --- a/pkg/gui/controllers/helpers/refresh_helper_test.go +++ b/pkg/gui/controllers/helpers/refresh_helper_test.go @@ -1,6 +1,7 @@ package helpers import ( + "path/filepath" "testing" "github.com/jesseduffield/lazygit/pkg/commands/hosting_service" @@ -243,6 +244,46 @@ func TestGetAuthenticatedGithubRemotes(t *testing.T) { }, callsByHost) } +func TestMarkWorktreeFiles(t *testing.T) { + worktreePath := filepath.Join("/", "path", "to", "repo") + worktrees := []*models.Worktree{ + {Path: worktreePath}, + {Path: filepath.Join(worktreePath, "worktree1")}, + {Path: filepath.Join(worktreePath, "dir", "worktree2")}, + {Path: filepath.Join("/", "path", "to", "worktree3")}, + } + + t.Run("marks the files that are worktrees, and takes their slash off", func(t *testing.T) { + files := []*models.File{ + {Path: "file"}, + {Path: "worktree1/"}, + {Path: "dir/worktree2/"}, + {Path: "dir/"}, + } + + assert.True(t, markWorktreeFiles(files, worktrees, worktreePath)) + assert.Equal(t, []*models.File{ + {Path: "file"}, + {Path: "worktree1", IsWorktree: true}, + {Path: "dir/worktree2", IsWorktree: true}, + {Path: "dir/"}, + }, files) + }) + + t.Run("reports no change when there is nothing to mark", func(t *testing.T) { + files := []*models.File{{Path: "file"}, {Path: "dir/"}} + + assert.False(t, markWorktreeFiles(files, worktrees, worktreePath)) + }) + + t.Run("unmarks a file whose worktree is gone", func(t *testing.T) { + files := []*models.File{{Path: "worktree1", IsWorktree: true}} + + assert.True(t, markWorktreeFiles(files, nil, worktreePath)) + assert.Equal(t, []*models.File{{Path: "worktree1"}}, files) + }) +} + func makeGithubRemoteInfoList(names ...string) []githubRemoteInfo { return lo.Map(names, func(name string, _ int) githubRemoteInfo { return makeGithubRemoteInfo(name, name) diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 096534e96..b922f0705 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -101,14 +101,25 @@ func (self *GuiDriver) replayMouseEventWithoutWaiting(x, y int, buttons tcell.Bu )) } -// FocusIn simulates the terminal window regaining focus, which is how lazygit -// learns to reload changed config files. Tests use it to exercise the live -// config-reload path. -func (self *GuiDriver) FocusIn() { +// replayFocusIn takes the focus away before handing it back, because that's the +// only way a terminal can report regaining it, and lazygit only reacts to focus +// reports that change the focus (see gocui.Gui.IsFocused). +func (self *GuiDriver) replayFocusIn() { + self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( + tcell.NewEventFocus(false), + 0, + )) self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( tcell.NewEventFocus(true), 0, )) +} + +// FocusIn simulates the terminal window regaining focus, which is how lazygit +// learns to reload changed config files. Tests use it to exercise the live +// config-reload path. +func (self *GuiDriver) FocusIn() { + self.replayFocusIn() self.waitTillIdle() } @@ -116,10 +127,7 @@ func (self *GuiDriver) FocusIn() { func (self *GuiDriver) FocusInAndClick(x, y int) { self.CheckAllToastsAcknowledged() - self.gui.g.ReplayFocusEvent(gocui.NewTcellFocusEventWrapper( - tcell.NewEventFocus(true), - 0, - )) + self.replayFocusIn() self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( tcell.NewEventMouse(x, y, tcell.ButtonPrimary, 0), 0, diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 16de7805a..fac10efb4 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -557,4 +557,5 @@ var tests = []*components.IntegrationTest{ worktree.SeparateWorkTreeConfig, worktree.SymlinkIntoRepoSubdir, worktree.WorktreeInRepo, + worktree.WorktreeInsideRepo, } diff --git a/pkg/integration/tests/worktree/worktree_inside_repo.go b/pkg/integration/tests/worktree/worktree_inside_repo.go new file mode 100644 index 000000000..bae2ff8f1 --- /dev/null +++ b/pkg/integration/tests/worktree/worktree_inside_repo.go @@ -0,0 +1,28 @@ +package worktree + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var WorktreeInsideRepo = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "A worktree that lives inside the repo's working tree is shown as a single item in the files panel", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) { + config.GetUserConfig().Gui.NerdFontsVersion = "3" + }, + SetupRepo: func(shell *Shell) { + shell.NewBranch("mybranch") + shell.CreateFileAndAdd("README.md", "hello world") + shell.Commit("initial commit") + shell.AddWorktree("mybranch", "nested-worktree", "newbranch") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Files(). + IsFocused(). + Lines( + Equals("?? 󰌹 nested-worktree").IsSelected(), + ) + }, +})