Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Contributing

## Table of contents

- [Getting started](#getting-started)
- [Development workflow](#development-workflow)
- [Contribution guidelines](#contribution-guidelines)
- [Adding or changing CLI behavior](#adding-or-changing-cli-behavior)
- [Documentation style](#documentation-style)
- [Reporting bugs](#reporting-bugs)
- [Feature requests](#feature-requests)

Thanks for your interest in contributing to `treels`.

## Getting started
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ Use it to inspect directories as a compact grid, expand them as a tree, hide pro
<img src="treels.png" alt="treels preview" width="300">
</p>

## Table of contents

- [Quick start](#quick-start)
- [Installation](#installation)
- [Features](#features)
- [Usage](#usage)
- [Preview](#preview)
- [Documentation](#documentation)
- [Development](#development)
- [License](#license)

## Quick start

```bash
Expand All @@ -20,6 +31,8 @@ treels --tree --depth 2 # tree view limited to two levels
treels --tree --gitignore # exclude root .gitignore matches
treels --tree --dirs-only # show directory structure only
treels --long --readable # detailed listing with readable sizes
treels --sort size --reverse # sort entries by largest first
treels --dirs-first # group directories before files
treels --json # machine-readable output
treels --no-icons # fallback for terminals without Nerd Fonts
```
Expand Down Expand Up @@ -66,6 +79,7 @@ go build .
| Depth limit | `--depth N` |
| Detailed metadata | `-l`, `--long` |
| Human-readable sizes | `-r`, `--readable` |
| Sorting | `--sort name|size|modified|type`, `--reverse`, `--dirs-first` |
| JSON output | `--json` |
| Hidden files | `-a`, `--all` |
| Directory-only view | `--dirs-only` |
Expand Down
3 changes: 3 additions & 0 deletions cmd/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ func FlagDefinition(cmd *cobra.Command, flags *module.Flags) {
cmd.PersistentFlags().BoolVar(&flags.HideIcon, "no-icons", false, "Disable icons")
cmd.PersistentFlags().BoolVar(&flags.HideSummary, "no-summary", false, "Hide the final file and directory count")
cmd.PersistentFlags().BoolVar(&flags.RespectGitIgnore, "gitignore", false, "Respect .gitignore rules")
cmd.PersistentFlags().StringVar(&flags.SortBy, "sort", "name", "Sort entries by name, size, modified, or type")
cmd.PersistentFlags().BoolVar(&flags.ReverseSort, "reverse", false, "Reverse sort order")
cmd.PersistentFlags().BoolVar(&flags.DirsFirst, "dirs-first", false, "Show directories before files")
cmd.PersistentFlags().IntVar(&flags.TreeDepth, "depth", -1, "Limit tree view recursion depth")
cmd.PersistentFlags().Lookup("depth").DefValue = "unlimited"
cmd.PersistentFlags().BoolVarP(&flags.ShowReadableSize, "readable", "r", false, "Show human-readable size for each file and directory")
Expand Down
12 changes: 12 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"io"
"os"
"strings"

"github.com/oussamaM1/treels/module"
"github.com/oussamaM1/treels/service"
Expand All @@ -13,6 +14,13 @@ import (

const version = "v1.3.1"

var validSortFields = map[string]struct{}{
"name": {},
"size": {},
"modified": {},
"type": {},
}

// Execute func - runs the root command.
func Execute() {
execute(newRootCmd(), os.Stderr, os.Exit)
Expand Down Expand Up @@ -40,6 +48,10 @@ func newRootCmd() *cobra.Command {
if cmd.Flags().Changed("depth") && flag.TreeDepth < 0 {
return fmt.Errorf("--depth must be greater than or equal to 0")
}
flag.SortBy = strings.ToLower(flag.SortBy)
if _, ok := validSortFields[flag.SortBy]; !ok {
return fmt.Errorf("--sort must be one of: name, size, modified, type")
}
flag.LimitTreeDepth = cmd.Flags().Changed("depth")

options := module.Options{Flags: flag}
Expand Down
71 changes: 71 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,62 @@ func TestRootCmd_LongFlag(t *testing.T) {
}
}

func TestRootCmd_SortFlags(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "small.txt"), []byte("x"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "large.txt"), []byte("1234567890"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := os.Mkdir(filepath.Join(dir, "folder"), 0o755); err != nil {
t.Fatalf("Mkdir() error = %v", err)
}

output := captureStdout(t, func() {
cmd := newRootCmd()
cmd.SetArgs([]string{"--long", "--no-icons", "--sort", "size", "--reverse", "--dirs-first", dir})

if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v, want nil", err)
}
})

assertOutputOrder(t, output, []string{"folder", "large.txt", "small.txt"})
}

func TestRootCmd_SortFlagRejectsInvalidValue(t *testing.T) {
cmd := newRootCmd()
cmd.SetArgs([]string{"--sort", "unknown"})

err := cmd.Execute()
if err == nil {
t.Fatal("Execute() error = nil, want invalid sort error")
}
if !strings.Contains(err.Error(), "--sort must be one of: name, size, modified, type") {
t.Fatalf("Execute() error = %q, want invalid sort validation error", err)
}
}

func TestRootCmd_SortFlagIsCaseInsensitive(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("x"), 0o644); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}

output := captureStdout(t, func() {
cmd := newRootCmd()
cmd.SetArgs([]string{"--sort", "SIZE", "--no-icons", dir})

if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v, want nil", err)
}
})
if !strings.Contains(output, "a.txt") {
t.Fatalf("Execute() output = %q, want sorted output", output)
}
}

func TestRootCmd_DepthFlag(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "subpkg"), 0o755); err != nil {
Expand Down Expand Up @@ -394,3 +450,18 @@ func captureStdout(t *testing.T, run func()) string {

return string(output)
}

func assertOutputOrder(t *testing.T, output string, orderedNames []string) {
t.Helper()
previousIndex := -1
for _, name := range orderedNames {
index := strings.Index(output, name)
if index == -1 {
t.Fatalf("output = %q, want to contain %q", output, name)
}
if index <= previousIndex {
t.Fatalf("output = %q, want %q after previous entries %v", output, name, orderedNames)
}
previousIndex = index
}
}
13 changes: 13 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Development guide

## Table of contents

- [Requirements](#requirements)
- [Setup](#setup)
- [Run locally](#run-locally)
- [Build](#build)
- [Test](#test)
- [Vet and lint](#vet-and-lint)
- [CI](#ci)
- [Project structure](#project-structure)
- [Adding a new flag](#adding-a-new-flag)
- [Testing documentation examples](#testing-documentation-examples)

This guide covers local development commands for `treels`.

## Requirements
Expand Down
8 changes: 8 additions & 0 deletions docs/gitignore.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Gitignore support

## Table of contents

- [Current behavior](#current-behavior)
- [Supported rule features](#supported-rule-features)
- [Examples](#examples)
- [Interaction with other flags](#interaction-with-other-flags)
- [Limitations](#limitations)

Use `--gitignore` to hide entries matched by `.gitignore` rules from the target directory.

```bash
Expand Down
7 changes: 7 additions & 0 deletions docs/icons.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Icons and fonts

## Table of contents

- [Requirements](#requirements)
- [Disable icons](#disable-icons)
- [Colors](#colors)
- [File type support](#file-type-support)

`treels` uses Nerd Font icons by default to make file types easier to scan.

## Requirements
Expand Down
11 changes: 11 additions & 0 deletions docs/json-output.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# JSON output

## Table of contents

- [Flat JSON example](#flat-json-example)
- [Tree JSON example](#tree-json-example)
- [Schema](#schema)
- [Flag behavior](#flag-behavior)
- [Stability notes](#stability-notes)

Use `--json` when `treels` output needs to be consumed by scripts or other tools.

```bash
Expand Down Expand Up @@ -116,6 +124,9 @@ Most filtering flags affect JSON output:
| `--all` | Includes hidden entries. |
| `--dirs-only` | Omits file entries. |
| `--gitignore` | Omits entries matched by the target directory's `.gitignore`. |
| `--sort name|size|modified|type` | Sorts JSON entries using the selected field. |
| `--reverse` | Reverses JSON entry order for the selected sort. |
| `--dirs-first` | Groups directory entries before file entries. |

Text formatting flags do not affect JSON output:

Expand Down
26 changes: 26 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Usage guide

## Table of contents

- [Output modes](#output-modes)
- [Common workflows](#common-workflows)
- [Flags](#flags)
- [Flag interactions](#flag-interactions)
- [Exit codes](#exit-codes)

`treels` lists one directory at a time. If no path is provided, it lists the current working directory.

```bash
Expand Down Expand Up @@ -121,6 +129,18 @@ treels --tree --dirs-only
treels --long --readable
```

### Sort by largest files first

```bash
treels --sort size --reverse --long --readable
```

### Show directories before files

```bash
treels --dirs-first
```

### Disable icons for plain terminals or logs

```bash
Expand All @@ -144,6 +164,9 @@ treels --json
| `--gitignore` | Respect `.gitignore` rules from the target directory. |
| `--json` | Output machine-readable JSON. |
| `-l`, `--long` | Show detailed file metadata. |
| `--sort name|size|modified|type` | Sort entries by name, size, modification time, or file type. Defaults to `name`. |
| `--reverse` | Reverse the selected sort order. |
| `--dirs-first` | Group directories before files. |
| `--no-icons` | Disable file and folder icons. |
| `--no-summary` | Hide the final text summary. |
| `-r`, `--readable` | Show human-readable file and directory sizes. |
Expand All @@ -158,6 +181,9 @@ treels --json
| `--tree --dirs-only` | Recursively shows directories while omitting files. |
| `--long --readable` | Shows human-readable sizes in the long metadata column. |
| `--tree --long` | Shows tree branches plus metadata for each entry. |
| `--sort size --reverse` | Shows largest entries first. |
| `--sort modified --reverse` | Shows newest entries first. |
| `--dirs-first --reverse` | Keeps directories grouped first, then reverses the selected sort within each group. |
| `--json --tree` | Emits recursive JSON with `children` arrays for directories. |
| `--json --long` | JSON output is unchanged; `--long` only affects text output. |
| `--gitignore --all` | Hidden files are included only if they are not ignored by `.gitignore`. |
Expand Down
3 changes: 3 additions & 0 deletions module/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ type Flags struct {
ShowLongFormat bool
HideSummary bool
RespectGitIgnore bool
SortBy string
ReverseSort bool
DirsFirst bool
TreeDepth int
LimitTreeDepth bool
}
Expand Down
4 changes: 2 additions & 2 deletions service/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func collectJSONFlatEntries(options directoryOptions) (entries []jsonEntry, summ
}
}()

sortSlice(files)
sortSlice(files, options.Flags)
for _, file := range files {
if !shouldShowFile(file, options) {
continue
Expand Down Expand Up @@ -103,7 +103,7 @@ func collectJSONTreeEntries(options directoryOptions, depth int) (entries []json
}
}()

sortSlice(files)
sortSlice(files, options.Flags)
for _, file := range files {
if !shouldShowFile(file, options) {
continue
Expand Down
8 changes: 4 additions & 4 deletions service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ func listDirectory(options directoryOptions, output io.Writer) (fileCount, dirCo
}
}()

// sort files by name
sortSlice(files)
// sort files by requested order
sortSlice(files, options.Flags)

// Collect formatted file entries
var entries []string
Expand Down Expand Up @@ -151,8 +151,8 @@ func treeDirectory(options directoryOptions, output io.Writer, indent string, is
}
}()

// Sort files by name
sortSlice(files)
// Sort files by requested order
sortSlice(files, options.Flags)

// Print files and directories
fc, dc, err := printFilesAndDirectoriesTreeFormat(files, options, output, indent, isLastFolder, depth)
Expand Down
Loading
Loading