diff --git a/docs/keybindings.md b/docs/keybindings.md index 919df3bf..68bb333d 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -27,6 +27,7 @@ Always active regardless of focused panel. | `/` | Fuzzy finder | | `:` | Command palette | | `~` | Change directory | +| `T` | Find TODO/FIXME markers | | `ctrl+space` | Toggle AI chat | | `ctrl+z` | Undo last git action | | `ctrl+y` | Redo | diff --git a/internal/keybindings/keybindings.json b/internal/keybindings/keybindings.json index 6c9e7045..c0689743 100644 --- a/internal/keybindings/keybindings.json +++ b/internal/keybindings/keybindings.json @@ -17,6 +17,7 @@ { "key": "/", "action": "Fuzzy finder" }, { "key": ":", "action": "Command palette" }, { "key": "~", "action": "Change directory" }, + { "key": "T", "action": "Find TODO/FIXME markers" }, { "key": "ctrl+space", "action": "Toggle AI chat" }, { "key": "ctrl+z", "action": "Undo last git action" }, { "key": "ctrl+y", "action": "Redo" }, diff --git a/internal/keymap/schemes/classic.toml b/internal/keymap/schemes/classic.toml index 18eaf9ec..a340972b 100644 --- a/internal/keymap/schemes/classic.toml +++ b/internal/keymap/schemes/classic.toml @@ -130,6 +130,12 @@ action = "change_directory" mode = "panel" description = "Change directory" +[[bindings]] +key = "T" +action = "todo_finder" +mode = "panel" +description = "Find TODO/FIXME markers" + [[bindings]] key = "/" action = "search" diff --git a/internal/keymap/schemes/default.toml b/internal/keymap/schemes/default.toml index 907d3b55..4cd3ba97 100644 --- a/internal/keymap/schemes/default.toml +++ b/internal/keymap/schemes/default.toml @@ -156,6 +156,12 @@ action = "change_directory" mode = "global" description = "Change directory" +[[bindings]] +key = "T" +action = "todo_finder" +mode = "global" +description = "Find TODO/FIXME markers" + # File tree specific [[bindings]] key = "h" diff --git a/internal/keymap/schemes/vim.toml b/internal/keymap/schemes/vim.toml index 377f2a4e..e882f207 100644 --- a/internal/keymap/schemes/vim.toml +++ b/internal/keymap/schemes/vim.toml @@ -160,6 +160,12 @@ action = "change_directory" mode = "panel" description = "Change directory" +[[bindings]] +key = "T" +action = "todo_finder" +mode = "panel" +description = "Find TODO/FIXME markers" + # File tree specific [[bindings]] key = "h" diff --git a/internal/overlayreg/overlayreg.go b/internal/overlayreg/overlayreg.go index dcdd2c7a..9cf9bb66 100644 --- a/internal/overlayreg/overlayreg.go +++ b/internal/overlayreg/overlayreg.go @@ -60,7 +60,7 @@ func (f *Factory) NewSettingsPanel( } // NewFuzzyFinder creates a fuzzy finder overlay for the given mode. -// Supported modes: "files", "commands", "directories". +// Supported modes: "files", "commands", "directories", "todos". // The bindings parameter is used for the "commands" mode source. func (f *Factory) NewFuzzyFinder(mode string, bindings []keymap.Binding) panels.Panel { cwd, err := os.Getwd() @@ -73,6 +73,7 @@ func (f *Factory) NewFuzzyFinder(mode string, bindings []keymap.Binding) panels. fuzzyfinder.NewCommandSource(bindings), fuzzyfinder.NewBookmarkSource(f.bookmarkMgr), fuzzyfinder.NewGitChangedSource(cwd), + fuzzyfinder.NewTodoSource(cwd), } defaultCategories := []string{fuzzyfinder.DefaultCategoryFile()} switch mode { @@ -82,6 +83,8 @@ func (f *Factory) NewFuzzyFinder(mode string, bindings []keymap.Binding) panels. defaultCategories = []string{fuzzyfinder.DefaultCategoryCommand()} case "directories": defaultCategories = []string{fuzzyfinder.DefaultCategoryDirectory()} + case "todos": + defaultCategories = []string{fuzzyfinder.DefaultCategoryTodo()} } return fuzzyfinder.NewWithDefaultCategories(f.theme, defaultCategories, sources...) } diff --git a/internal/panels/fuzzyfinder/constants.go b/internal/panels/fuzzyfinder/constants.go index 36b6cce6..d45f345f 100644 --- a/internal/panels/fuzzyfinder/constants.go +++ b/internal/panels/fuzzyfinder/constants.go @@ -6,6 +6,7 @@ const ( categoryDirectory = "directory" categoryFile = "file" categoryGitChanged = "git" + categoryTodo = "todo" actionCursorDown = "cursor_down" dirGit = ".git" ) @@ -17,6 +18,7 @@ const ( sourceNameCommands = "commands" sourceNameBookmarks = "bookmarks" sourceNameGitChanged = "git changed" + sourceNameTodos = "todos" ) // DefaultCategoryFile returns the file category name for overlay factories. @@ -27,3 +29,6 @@ func DefaultCategoryCommand() string { return categoryCommand } // DefaultCategoryDirectory returns the directory category name for overlay factories. func DefaultCategoryDirectory() string { return categoryDirectory } + +// DefaultCategoryTodo returns the todo category name for overlay factories. +func DefaultCategoryTodo() string { return categoryTodo } diff --git a/internal/panels/fuzzyfinder/fuzzyfinder.go b/internal/panels/fuzzyfinder/fuzzyfinder.go index fe14b831..46031a04 100644 --- a/internal/panels/fuzzyfinder/fuzzyfinder.go +++ b/internal/panels/fuzzyfinder/fuzzyfinder.go @@ -154,6 +154,8 @@ func categoriesForPrefix(prefix byte) (map[string]bool, string, bool) { return categorySet(categoryBookmark), sourceNameBookmarks, true case 'g', 'G': return categorySet(categoryGitChanged), sourceNameGitChanged, true + case 't', 'T': + return categorySet(categoryTodo), sourceNameTodos, true default: return nil, "", false } @@ -182,6 +184,8 @@ func sourceLabelForCategories(categories map[string]bool) string { return sourceNameBookmarks case categories[categoryGitChanged]: return sourceNameGitChanged + case categories[categoryTodo]: + return sourceNameTodos default: return "" } @@ -399,7 +403,7 @@ func (ff *FuzzyFinder) selectCurrent() tea.Cmd { if !ok { return nil } - return panels.FileSelectedMsg{Path: path} + return panels.FileSelectedMsg{Path: path, Line: item.Line} } }, func() tea.Msg { diff --git a/internal/panels/fuzzyfinder/fuzzyfinder_test.go b/internal/panels/fuzzyfinder/fuzzyfinder_test.go index 0cc5ec4a..1a1a8ff5 100644 --- a/internal/panels/fuzzyfinder/fuzzyfinder_test.go +++ b/internal/panels/fuzzyfinder/fuzzyfinder_test.go @@ -537,6 +537,44 @@ func TestCtrlUClearsQuery(t *testing.T) { // Command selection tests // --------------------------------------------------------------------------- +func collectBatchMsgs(cmd tea.Cmd) []tea.Msg { + if cmd == nil { + return nil + } + msg := cmd() + if batch, ok := msg.(tea.BatchMsg); ok { + var out []tea.Msg + for _, c := range batch { + out = append(out, collectBatchMsgs(c)...) + } + return out + } + if msg != nil { + return []tea.Msg{msg} + } + return nil +} + +func TestSelectCurrent_TodoItemCarriesLine(t *testing.T) { + items := []Item{ + {Text: "main.go:42 TODO fix", Category: categoryTodo, Value: "/abs/main.go", Line: 42}, + } + ff := newTestFinder(items) + + _, cmd := ff.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) + require.NotNil(t, cmd) + + found := false + for _, m := range collectBatchMsgs(cmd) { + if fs, ok := m.(panels.FileSelectedMsg); ok { + assert.Equal(t, "/abs/main.go", fs.Path) + assert.Equal(t, 42, fs.Line) + found = true + } + } + assert.True(t, found, "expected a FileSelectedMsg carrying the todo line") +} + func TestCommandSourceSelectReturnsCommandItem(t *testing.T) { bindings := []keymap.Binding{ {Key: "ctrl+c", Action: "quit", Description: "Quit grut", Mode: keymap.ModeGlobal}, diff --git a/internal/panels/fuzzyfinder/source.go b/internal/panels/fuzzyfinder/source.go index c55c6bb2..00ed1f05 100644 --- a/internal/panels/fuzzyfinder/source.go +++ b/internal/panels/fuzzyfinder/source.go @@ -93,6 +93,7 @@ type Item struct { Text string // Searchable text (what fuzzy matches against) Description string // Secondary text shown in results Category string // Source category for display grouping + Line int // 1-based line to jump to when selected (0 = none) } // --------------------------------------------------------------------------- diff --git a/internal/panels/fuzzyfinder/todosource.go b/internal/panels/fuzzyfinder/todosource.go new file mode 100644 index 00000000..5706aedc --- /dev/null +++ b/internal/panels/fuzzyfinder/todosource.go @@ -0,0 +1,158 @@ +package fuzzyfinder + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Code-annotation markers recognized by the todo source. +const ( + // maxTodoFileSize is the largest file the scanner reads. Files above this + // size are skipped to keep the scan fast and avoid pulling large blobs + // into memory. + maxTodoFileSize = 1 << 20 // 1 MiB + // maxTodoItems caps the number of results so a repository full of markers + // cannot produce an unbounded list. + maxTodoItems = 5000 + // binarySniffLen is the number of leading bytes inspected for a NUL byte + // when deciding whether a file is binary. + binarySniffLen = 8192 +) + +// todoMarkerRe matches TODO, FIXME, HACK, BUG, and XXX when they appear as +// standalone uppercase tokens (not as part of a longer identifier). The +// surrounding boundaries are checked with lookarounds emulated by requiring a +// non-word character or string edge on each side. +var todoMarkerRe = regexp.MustCompile(`(^|[^0-9A-Za-z_])(TODO|FIXME|HACK|BUG|XXX)([^0-9A-Za-z_]|$)`) + +// TodoSource scans tracked text files for code-annotation markers (TODO, +// FIXME, HACK, BUG, XXX) and lists each occurrence as a file:line entry. It +// reuses the same hidden-directory, non-navigable-directory, and .gitignore +// filtering as the file source so results stay scoped to source you actually +// track. +type TodoSource struct { + root string +} + +// NewTodoSource creates a source that scans the given root directory for code +// annotations. +func NewTodoSource(root string) *TodoSource { + return &TodoSource{root: root} +} + +// Name implements Source. +func (ts *TodoSource) Name() string { return sourceNameTodos } + +// Items implements Source. It walks the tree rooted at ts.root, reads each +// tracked text file, and emits one item per annotation marker found. +func (ts *TodoSource) Items() []Item { + if ts == nil || ts.root == "" { + return nil + } + gi := loadGitIgnore(ts.root) + var items []Item + _ = filepath.WalkDir(ts.root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil //nolint:nilerr // non-matching entries silently skipped + } + if len(items) >= maxTodoItems { + return filepath.SkipAll + } + name := d.Name() + if name == dirGit && d.IsDir() { + return filepath.SkipDir + } + // Skip hidden directories and files. + if name != "." && strings.HasPrefix(name, ".") { + if d.IsDir() { + return filepath.SkipDir + } + return nil + } + if d.IsDir() { + if nonNavigableDirs[name] { + return filepath.SkipDir + } + return nil + } + rel, relErr := filepath.Rel(ts.root, path) + if relErr != nil { + rel = path + } + rel = filepath.ToSlash(rel) + if gi != nil && gi.MatchesPath(rel) { + return nil + } + items = append(items, scanFileTodos(path, rel)...) + if len(items) >= maxTodoItems { + items = items[:maxTodoItems] + return filepath.SkipAll + } + return nil + }) + return items +} + +// scanFileTodos reads a single file and returns an item for every annotation +// marker it contains. It skips files that are too large or that look binary. +func scanFileTodos(path, rel string) []Item { + info, err := os.Stat(path) + if err != nil || info.Size() > maxTodoFileSize { + return nil + } + data, err := os.ReadFile(path) + if err != nil || isBinary(data) { + return nil + } + var items []Item + lines := strings.Split(string(data), "\n") + for i, line := range lines { + marker, message, ok := matchTodoLine(line) + if !ok { + continue + } + lineNo := i + 1 + items = append(items, Item{ + Text: fmt.Sprintf("%s:%d %s %s", rel, lineNo, marker, message), + Description: marker, + Category: categoryTodo, + Value: path, + Line: lineNo, + }) + } + return items +} + +// matchTodoLine returns the marker and trailing annotation text for the first +// marker found on a line. ok is false when the line has no marker. +func matchTodoLine(line string) (marker, message string, ok bool) { + loc := todoMarkerRe.FindStringSubmatchIndex(line) + if loc == nil { + return "", "", false + } + // Submatch group 2 is the marker token (indices loc[4]:loc[5]). + marker = line[loc[4]:loc[5]] + // The annotation message is everything from the marker to the end of the + // line, with a leading colon and surrounding whitespace trimmed. + rest := strings.TrimSpace(line[loc[5]:]) + rest = strings.TrimPrefix(rest, ":") + message = strings.TrimSpace(rest) + const maxMessageLen = 200 + if len(message) > maxMessageLen { + message = message[:maxMessageLen] + } + return marker, message, true +} + +// isBinary reports whether data looks like a binary file by checking the first +// chunk for a NUL byte, which almost never appears in text. +func isBinary(data []byte) bool { + if len(data) > binarySniffLen { + data = data[:binarySniffLen] + } + return bytes.IndexByte(data, 0) >= 0 +} diff --git a/internal/panels/fuzzyfinder/todosource_test.go b/internal/panels/fuzzyfinder/todosource_test.go new file mode 100644 index 00000000..3cda588c --- /dev/null +++ b/internal/panels/fuzzyfinder/todosource_test.go @@ -0,0 +1,124 @@ +package fuzzyfinder + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeTodoFile(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + return path +} + +func TestTodoSource_ScansMarkers(t *testing.T) { + dir := t.TempDir() + content := "package main\n" + // line 1 + "// TODO: wire up the thing\n" + // line 2 + "func main() {}\n" + // line 3 + "// FIXME broken on windows\n" + // line 4 + "// HACK temporary shim\n" + // line 5 + "// BUG off-by-one here\n" + // line 6 + "// XXX revisit\n" // line 7 + writeTodoFile(t, dir, "main.go", content) + + items := NewTodoSource(dir).Items() + require.Len(t, items, 5) + + byLine := make(map[int]Item) + for _, it := range items { + assert.Equal(t, categoryTodo, it.Category) + byLine[it.Line] = it + } + assert.Equal(t, "TODO", byLine[2].Description) + assert.Contains(t, byLine[2].Text, "wire up the thing") + assert.Equal(t, "FIXME", byLine[4].Description) + assert.Equal(t, "HACK", byLine[5].Description) + assert.Equal(t, "BUG", byLine[6].Description) + assert.Equal(t, "XXX", byLine[7].Description) +} + +func TestTodoSource_ValuePointsAtAbsolutePath(t *testing.T) { + dir := t.TempDir() + path := writeTodoFile(t, dir, "notes.go", "// TODO fix\n") + + items := NewTodoSource(dir).Items() + require.Len(t, items, 1) + assert.Equal(t, path, items[0].Value) + assert.Equal(t, 1, items[0].Line) +} + +func TestTodoSource_IgnoresBinary(t *testing.T) { + dir := t.TempDir() + // A NUL byte in the first chunk marks the file as binary. + writeTodoFile(t, dir, "blob.bin", "\x00// TODO should be ignored\n") + + items := NewTodoSource(dir).Items() + assert.Empty(t, items) +} + +func TestTodoSource_RespectsGitignore(t *testing.T) { + dir := t.TempDir() + writeTodoFile(t, dir, ".gitignore", "ignored.go\n") + writeTodoFile(t, dir, "ignored.go", "// TODO hidden by gitignore\n") + writeTodoFile(t, dir, "kept.go", "// TODO visible\n") + + items := NewTodoSource(dir).Items() + require.Len(t, items, 1) + assert.Contains(t, items[0].Text, "visible") +} + +func TestTodoSource_SkipsNonNavigableDirs(t *testing.T) { + dir := t.TempDir() + nested := filepath.Join(dir, "node_modules", "pkg") + require.NoError(t, os.MkdirAll(nested, 0o755)) + writeTodoFile(t, nested, "index.js", "// TODO in dependency\n") + writeTodoFile(t, dir, "app.go", "// TODO in source\n") + + items := NewTodoSource(dir).Items() + require.Len(t, items, 1) + assert.Contains(t, items[0].Text, "in source") +} + +func TestTodoSource_NilAndEmptyRoot(t *testing.T) { + var ts *TodoSource + assert.Nil(t, ts.Items()) + assert.Nil(t, NewTodoSource("").Items()) +} + +func TestMatchTodoLine(t *testing.T) { + tests := []struct { + name string + line string + wantMarker string + wantMessage string + wantOK bool + }{ + {"todo with colon", "// TODO: do the work", "TODO", "do the work", true}, + {"fixme no colon", " # FIXME broken", "FIXME", "broken", true}, + {"marker at end", "value // XXX", "XXX", "", true}, + {"embedded in word not matched", "autoTODOlist := 1", "", "", false}, + {"lowercase not matched", "// todo lowercase", "", "", false}, + {"no marker", "just a normal line", "", "", false}, + {"hack marker", "//HACK:patch", "HACK", "patch", true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + marker, message, ok := matchTodoLine(tc.line) + assert.Equal(t, tc.wantOK, ok) + assert.Equal(t, tc.wantMarker, marker) + assert.Equal(t, tc.wantMessage, message) + }) + } +} + +func TestIsBinary(t *testing.T) { + assert.True(t, isBinary([]byte{'a', 0, 'b'})) + assert.False(t, isBinary([]byte("plain text content"))) + assert.False(t, isBinary(nil)) +} diff --git a/internal/panels/messages.go b/internal/panels/messages.go index c7c78090..a893f844 100644 --- a/internal/panels/messages.go +++ b/internal/panels/messages.go @@ -49,6 +49,7 @@ type DiffContext struct { type FileSelectedMsg struct { Path string DiffContext *DiffContext // nil = working tree diff (backward compatible) + Line int // 1-based line to scroll to after load (0 = none) } // RevealFileMsg requests the filetree to expand parent directories and diff --git a/internal/panels/preview/gotoline_jump_test.go b/internal/panels/preview/gotoline_jump_test.go new file mode 100644 index 00000000..d4a10f12 --- /dev/null +++ b/internal/panels/preview/gotoline_jump_test.go @@ -0,0 +1,69 @@ +package preview + +import ( + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jongio/grut/internal/panels" +) + +// loadFileAtLine mirrors loadFile but carries a target line, running the async +// load command so the fileLoadedMsg is fed back into the panel. +func loadFileAtLine(t *testing.T, p *Preview, path string, line int) { + t.Helper() + _, cmd := p.Update(panels.FileSelectedMsg{Path: path, Line: line}) + require.NotNil(t, cmd) + msg := cmd() + if msg != nil { + p.Update(msg) + } +} + +func makeNumberedLines(n int) string { + var b strings.Builder + for i := 1; i <= n; i++ { + b.WriteString("line ") + b.WriteString(strconv.Itoa(i)) + b.WriteByte('\n') + } + return b.String() +} + +func TestFileSelected_JumpsToLine(t *testing.T) { + dir := t.TempDir() + path := writeFile(t, dir, "big.txt", makeNumberedLines(100)) + + cfg := defaultCfg() + cfg.SyntaxHighlighting = false + p := New(cfg, defaultEditorCfg(), nil) + p.SetSize(60, 20) + + loadFileAtLine(t, p, path, 50) + + assert.Equal(t, 0, p.pendingGotoLine, "pending line should reset after load") + assert.Positive(t, p.scrollY, "should scroll down toward the target line") + + // The target line (index 49, 0-based) must be inside the viewport. + vh := p.viewportHeight() + assert.LessOrEqual(t, p.scrollY, 49) + assert.Less(t, 49, p.scrollY+vh) +} + +func TestFileSelected_NoLineStaysAtTop(t *testing.T) { + dir := t.TempDir() + path := writeFile(t, dir, "big.txt", makeNumberedLines(100)) + + cfg := defaultCfg() + cfg.SyntaxHighlighting = false + p := New(cfg, defaultEditorCfg(), nil) + p.SetSize(60, 20) + + loadFileAtLine(t, p, path, 0) + + assert.Equal(t, 0, p.scrollY, "no target line should leave the view at the top") + assert.Equal(t, 0, p.pendingGotoLine) +} diff --git a/internal/panels/preview/preview.go b/internal/panels/preview/preview.go index 9ef8dd3c..6deff7a7 100644 --- a/internal/panels/preview/preview.go +++ b/internal/panels/preview/preview.go @@ -64,6 +64,10 @@ type Preview struct { // as a line-number entry and scrolls to the entered line on Enter. gotoLineActive bool gotoLineInput string + // pendingGotoLine holds a 1-based line to scroll to once the current file + // finishes loading. It is set when a FileSelectedMsg carries a target line + // (for example from the todo fuzzy finder) and cleared after it is applied. + pendingGotoLine int // GitHub content mode – when a GitHub item (issue/PR/action run) is // selected, the preview shows the item detail instead of a file. ghMode bool // true when showing GitHub content instead of file @@ -263,6 +267,7 @@ func (p *Preview) Update(msg tea.Msg) (panels.Panel, tea.Cmd) { p.blameMode = false p.blameLines = nil p.diffLines = nil + p.pendingGotoLine = msg.Line cmds := []tea.Cmd{p.loadFileCmd(msg.Path)} if p.gitClient != nil { cmds = append(cmds, p.loadContextDiffCmd(msg.Path, p.diffContext)) @@ -276,6 +281,11 @@ func (p *Preview) Update(msg tea.Msg) (panels.Panel, tea.Cmd) { p.err = msg.err p.isBinary = msg.isBinary p.isLarge = msg.isLarge + // Honor a pending line jump now that content is available. + if p.pendingGotoLine > 0 && msg.err == nil && !msg.isBinary && !msg.isLarge { + p.gotoLine(p.pendingGotoLine) + } + p.pendingGotoLine = 0 } return p, nil case diffLoadedMsg: diff --git a/internal/tui/app.go b/internal/tui/app.go index 510b121e..1fa9d824 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -366,6 +366,8 @@ func (m Model) handleAction(action string, msg tea.Msg) (tea.Model, tea.Cmd) { return m.openFuzzyFinder("commands"), nil case "change_directory": return m.openFuzzyFinder("directories"), nil + case "todo_finder": + return m.openFuzzyFinder("todos"), nil case "help": return m.toggleHelp() case "welcome": diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 31262cc7..09b8ab59 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -609,6 +609,26 @@ func TestCommandPaletteAction(t *testing.T) { assert.NotNil(t, m.fuzzyFinder, "command_palette action should open fuzzy finder") } +func TestTodoFinderAction(t *testing.T) { + m := newTestModel(t) + updated, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40}) + m = updated.(Model) + m.Init() + + // The "todo_finder" action should open the fuzzy finder in todos mode. + updated, _ = m.handleAction("todo_finder", tea.KeyPressMsg{}) + m = updated.(Model) + assert.NotNil(t, m.fuzzyFinder, "todo_finder action should open fuzzy finder") +} + +func TestOpenFuzzyFinderTodosMode(t *testing.T) { + m := newTestModel(t) + m.width = 100 + m.height = 40 + m = m.openFuzzyFinder("todos") + assert.NotNil(t, m.fuzzyFinder, "fuzzy finder should be created for todos mode") +} + // --------------------------------------------------------------------------- // Change directory tests // ---------------------------------------------------------------------------