Skip to content

Commit de68565

Browse files
authored
Merge pull request #16 from epilande/devicons
feat(ui): Add nerd font icon support
2 parents 8b17420 + b69bb2c commit de68565

File tree

9 files changed

+144
-87
lines changed

9 files changed

+144
-87
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ grab [options] [directory]
104104
| `--max-depth <depth>` | Maximum depth for dependency resolution (`-1` for unlimited, default: `1`). Only effective with `--deps`. |
105105
| `--max-file-size <size>` | Maximum file size to include (e.g., `"100kb"`, `"2MB"`). No limit by default. Files exceeding the specified size will be skipped. |
106106
| `--theme <name>` | Set the UI theme. Available: catppuccin-latte, catppuccin-frappe, catppuccin-macchiato, catppuccin-mocha, rose-pine, rose-pine-dawn, rose-pine-moon, dracula, nord. (default: `"catppuccin-mocha"`). |
107-
| `--show-tokens` | Show the number of tokens for each file in file tree. |
107+
| `--show-tokens` | Show the number of tokens for each file in file tree. |
108+
| `--icons` | Display Nerd Font icons. |
108109

109110
### 📖 Examples
110111

cmd/grab/main.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ func main() {
4747
var resolveDeps bool
4848
var maxDepth int
4949
var maxFileSizeStr string
50+
var showIcons bool
5051
var showTokenCount bool
5152

5253
flag.BoolVar(&showHelp, "help", false, "Display help information")
@@ -86,6 +87,8 @@ func main() {
8687
maxFileSizeUsage := "Maximum file size to include (e.g., 50kb, 2MB). No limit by default."
8788
flag.StringVar(&maxFileSizeStr, "max-file-size", "", maxFileSizeUsage)
8889

90+
flag.BoolVar(&showIcons, "icons", false, "Display Nerd Font icons")
91+
8992
flag.BoolVar(&showTokenCount, "show-tokens", false, "Show the number of tokens for each file")
9093

9194
flag.Parse()
@@ -164,9 +167,10 @@ func main() {
164167
Format: formatName,
165168
SkipRedaction: skipRedaction,
166169
ResolveDeps: resolveDeps,
170+
ShowIcons: showIcons,
171+
ShowTokenCount: showTokenCount,
167172
MaxDepth: maxDepth,
168173
MaxFileSize: maxFileSize,
169-
ShowTokenCount: showTokenCount,
170174
}
171175

172176
m := model.NewModel(config)

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ require (
77
github.com/charmbracelet/bubbles v0.20.0
88
github.com/charmbracelet/bubbletea v1.2.4
99
github.com/charmbracelet/lipgloss v1.0.0
10+
github.com/epilande/go-devicons v0.0.0-20250502062109-89b44a507be9
1011
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
1112
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
1213
github.com/zricethezav/gitleaks/v8 v8.24.2

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
2727
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2828
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
2929
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
30+
github.com/epilande/go-devicons v0.0.0-20250430064519-26ca6d272b4c h1:ZKFmMr4EQ+6wni8gHq56tEmjMdIDySQ+zFgwXi+yCgc=
31+
github.com/epilande/go-devicons v0.0.0-20250430064519-26ca6d272b4c/go.mod h1:myBNrCUxmCh3ktYaRUMfL8epmWMBu6/yj0JFnQHYFSU=
32+
github.com/epilande/go-devicons v0.0.0-20250502062109-89b44a507be9 h1:wu8xPucxiGFKrSN24fE9VVzjthnfVqYFx33bNoRL8DU=
33+
github.com/epilande/go-devicons v0.0.0-20250502062109-89b44a507be9/go.mod h1:myBNrCUxmCh3ktYaRUMfL8epmWMBu6/yj0JFnQHYFSU=
3034
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
3135
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
3236
github.com/fatih/semgroup v1.2.0 h1:h/OLXwEM+3NNyAdZEpMiH1OzfplU09i2qXPVThGZvyg=

internal/model/display.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,10 @@ import (
77
"strings"
88

99
"github.com/epilande/codegrab/internal/filesystem"
10+
"github.com/epilande/go-devicons"
1011
)
1112

1213
// buildDisplayNodes constructs a hierarchical view of files and directories for display.
13-
// It creates FileNode objects for each item, properly indented based on directory depth,
14-
// respects collapsed/expanded states of directories, marks selected/deselected items,
15-
// identifies dependencies, and sorts files and directories.
16-
// The resulting displayNodes slice is used for rendering the file tree in the UI.
1714
func (m *Model) buildDisplayNodes() {
1815
m.displayNodes = nil
1916
nodesToAdd := make(map[string]filesystem.FileItem)
@@ -31,6 +28,15 @@ func (m *Model) buildDisplayNodes() {
3128
}
3229
processed[item.Path] = true
3330

31+
icon := ""
32+
iconColor := ""
33+
if m.showIcons {
34+
fullPath := filepath.Join(m.rootPath, item.Path)
35+
style := devicons.IconForPath(fullPath)
36+
icon = style.Icon
37+
iconColor = style.Color
38+
}
39+
3440
node := FileNode{
3541
Path: item.Path,
3642
Name: filepath.Base(item.Path),
@@ -39,6 +45,8 @@ func (m *Model) buildDisplayNodes() {
3945
Selected: m.selected[item.Path],
4046
IsDeselected: m.deselected[item.Path],
4147
IsDependency: m.isDependency[item.Path],
48+
Icon: icon,
49+
IconColor: iconColor,
4250
}
4351
m.displayNodes = append(m.displayNodes, node)
4452

@@ -55,6 +63,7 @@ func (m *Model) buildDisplayNodes() {
5563
}
5664
}
5765
}
66+
5867
for _, childItem := range directChildren {
5968
children = append(children, childItem)
6069
}

internal/model/model.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import (
1818
type FileNode struct {
1919
Path string
2020
Name string
21+
Icon string
22+
IconColor string
2123
Level int
2224
IsDir bool
2325
IsLast bool
@@ -52,6 +54,7 @@ type Model struct {
5254
showHelp bool
5355
useGitIgnore bool
5456
showHidden bool
57+
showIcons bool
5558
isSearching bool
5659
isGrabbing bool
5760
redactSecrets bool
@@ -69,6 +72,7 @@ type Config struct {
6972
UseTempFile bool
7073
SkipRedaction bool
7174
ResolveDeps bool
75+
ShowIcons bool
7276
ShowTokenCount bool
7377
}
7478

@@ -98,6 +102,7 @@ func NewModel(config Config) Model {
98102
generator: gen,
99103
redactSecrets: !config.SkipRedaction,
100104
resolveDeps: config.ResolveDeps,
105+
showIcons: config.ShowIcons,
101106
maxDepth: config.MaxDepth,
102107
maxFileSize: config.MaxFileSize,
103108
projectModuleName: moduleName,

internal/model/view.go

Lines changed: 63 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ func (m *Model) refreshViewportContent() {
169169
}
170170

171171
var lines []string
172-
parentIsLast := make([]bool, 100)
172+
parentIsLast := make(map[int]bool)
173173

174174
for i, node := range nodes {
175175
var prefixBuilder strings.Builder
@@ -184,27 +184,26 @@ func (m *Model) refreshViewportContent() {
184184
treeBranch := "├── "
185185
if node.IsLast {
186186
treeBranch = "└── "
187-
if node.Level >= 0 {
188-
parentIsLast[node.Level] = true
189-
}
187+
parentIsLast[node.Level] = true
190188
} else {
191-
if node.Level >= 0 {
192-
parentIsLast[node.Level] = false
193-
}
189+
parentIsLast[node.Level] = false
194190
}
195191
for l := node.Level + 1; l < len(parentIsLast); l++ {
196192
parentIsLast[l] = false
197193
}
198194

199195
treePrefix := prefixBuilder.String() + treeBranch
200196

201-
rawDirIndicator := ""
202-
if node.IsDir {
197+
icon := node.Icon
198+
iconColor := node.IconColor
199+
if node.IsDir && icon == "" {
203200
if m.collapsed[node.Path] {
204-
rawDirIndicator = " "
201+
icon = ""
205202
} else {
206-
rawDirIndicator = " "
203+
icon = ""
207204
}
205+
// Fallback to use theme directory color
206+
iconColor = ""
208207
}
209208

210209
isPartialDir := !m.selected[node.Path] && dirsWithSelectedChildren[node.Path] && node.IsDir
@@ -250,7 +249,8 @@ func (m *Model) refreshViewportContent() {
250249
rendered := ui.StyleFileLine(
251250
rawCheckbox,
252251
treePrefix,
253-
rawDirIndicator,
252+
icon,
253+
iconColor,
254254
rawName,
255255
rawSuffix,
256256
node.IsDir,
@@ -293,59 +293,76 @@ func (m *Model) getTotalFileCount() int {
293293
return count
294294
}
295295

296+
// getSelectedFileCount calculates the effective number of selected files.
297+
// This considers explicitly selected files, files within selected directories
298+
// (respecting search results), and excludes deselected files.
296299
func (m *Model) getSelectedFileCount() int {
297-
selectedCount := 0
300+
effectiveSelection := make(map[string]bool)
298301

299-
userSelectedFiles := make(map[string]bool)
300-
for p, sel := range m.selected {
301-
if sel && !m.isDependency[p] {
302-
userSelectedFiles[p] = true
303-
}
304-
}
305-
selectedDirs := make(map[string]bool)
306-
for p := range userSelectedFiles {
307-
for _, f := range m.files {
308-
if f.Path == p && f.IsDir {
309-
selectedDirs[p] = true
310-
break
311-
}
302+
searchResultPaths := make(map[string]bool)
303+
useSearchResults := m.isSearching && len(m.searchResults) > 0
304+
if useSearchResults {
305+
for _, node := range m.searchResults {
306+
searchResultPaths[node.Path] = true
312307
}
313308
}
314309

315-
for _, file := range m.files {
316-
if file.IsDir {
310+
for _, item := range m.files {
311+
if effectiveSelection[item.Path] {
317312
continue
318313
}
319314

320-
if m.deselected[file.Path] {
315+
if m.deselected[item.Path] {
321316
continue
322317
}
323318

324-
if m.selected[file.Path] {
325-
selectedCount++
326-
continue
319+
if useSearchResults && !searchResultPaths[item.Path] {
320+
isChildOfSearchResult := false
321+
parent := filepath.Dir(item.Path)
322+
for parent != "." && parent != "/" {
323+
if searchResultPaths[parent] {
324+
isChildOfSearchResult = true
325+
break
326+
}
327+
parent = filepath.Dir(parent)
328+
}
329+
if !isChildOfSearchResult {
330+
continue
331+
}
327332
}
328333

329-
currentDir := filepath.Dir(file.Path)
330-
isInSelectedDir := false
331-
for currentDir != "." && currentDir != "/" {
332-
if selectedDirs[currentDir] {
333-
isInSelectedDir = true
334-
break
334+
if m.selected[item.Path] {
335+
if !item.IsDir {
336+
effectiveSelection[item.Path] = true
337+
} else {
338+
prefix := item.Path + string(os.PathSeparator)
339+
for _, child := range m.files {
340+
if !child.IsDir && strings.HasPrefix(child.Path, prefix) && !m.deselected[child.Path] {
341+
if useSearchResults && !searchResultPaths[child.Path] {
342+
continue
343+
}
344+
effectiveSelection[child.Path] = true
345+
}
346+
}
335347
}
336-
currentDir = filepath.Dir(currentDir)
348+
continue
337349
}
338350

339-
if isInSelectedDir {
340-
if m.isSearching && len(m.searchResults) > 0 {
341-
if m.isInSearchResults(file.Path) {
342-
selectedCount++
351+
if !item.IsDir {
352+
parent := filepath.Dir(item.Path)
353+
isInSelectedDir := false
354+
for parent != "." && parent != "/" {
355+
if m.selected[parent] {
356+
isInSelectedDir = true
357+
break
343358
}
344-
} else {
345-
selectedCount++
359+
parent = filepath.Dir(parent)
360+
}
361+
if isInSelectedDir {
362+
effectiveSelection[item.Path] = true
346363
}
347364
}
348365
}
349366

350-
return selectedCount
367+
return len(effectiveSelection)
351368
}

internal/ui/help.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,9 @@ const UsageText = `Usage:
5353
Files exceeding this size will be skipped if the limit is set.
5454
--theme <name> Set the UI theme. Available: catppuccin-latte, catppuccin-frappe,
5555
catppuccin-macchiato, catppuccin-mocha, rose-pine, rose-pine-dawn,
56-
rose-pine-moon, dracula, nord. (default: "catppuccin-mocha").
56+
rose-pine-moon, dracula, nord. (default: "catppuccin-mocha").
5757
--show-tokens Show the number of tokens for each file in file tree.
58+
--icons Display Nerd Font icons.
5859
5960
Examples:
6061
# Run interactively in the current directory

0 commit comments

Comments
 (0)