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
13 changes: 9 additions & 4 deletions src-tauri/src/commands/fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ pub struct FileNode {
///
/// Hidden files/folders (starting with `.`) are excluded by default.
/// Only markdown and common text files are included; all directories are traversed.
fn read_dir_recursive(dir_path: &Path) -> Result<Vec<FileNode>, String> {
fn read_dir_recursive(dir_path: &Path, max_depth: u32) -> Result<Vec<FileNode>, String> {
let entries = fs::read_dir(dir_path)
.map_err(|e| format!("Failed to read directory '{}': {}", dir_path.display(), e))?;

Expand All @@ -141,7 +141,11 @@ fn read_dir_recursive(dir_path: &Path) -> Result<Vec<FileNode>, String> {
.map_err(|e| format!("Failed to read metadata for '{}': {}", path.display(), e))?;

if metadata.is_dir() {
let children = read_dir_recursive(&path)?;
let children = if max_depth > 0 {
read_dir_recursive(&path, max_depth - 1)?
} else {
Vec::new()
};
nodes.push(FileNode {
name: file_name,
path: path.to_string_lossy().to_string(),
Expand Down Expand Up @@ -205,7 +209,7 @@ fn read_dir_recursive(dir_path: &Path) -> Result<Vec<FileNode>, String> {
/// # Returns
/// A `FileNode` representing the root folder with its full recursive tree.
#[tauri::command]
pub fn read_directory_tree(path: String) -> Result<FileNode, String> {
pub fn read_directory_tree(path: String, max_depth: Option<u32>) -> Result<FileNode, String> {
let dir_path = Path::new(&path);

if !dir_path.exists() {
Expand All @@ -216,7 +220,8 @@ pub fn read_directory_tree(path: String) -> Result<FileNode, String> {
return Err(format!("Path '{}' is not a directory", path));
}

let children = read_dir_recursive(dir_path)?;
let depth = max_depth.unwrap_or(10);
let children = read_dir_recursive(dir_path, depth)?;

let root_name = dir_path
.file_name()
Expand Down
102 changes: 99 additions & 3 deletions src/__tests__/stores/sidebar.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useSidebarStore } from '../../stores/sidebar'
import { invoke } from '@tauri-apps/api/core'

const mockedInvoke = vi.mocked(invoke)

describe('useSidebarStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})

it('defaults to not visible with no root path', () => {
Expand Down Expand Up @@ -85,8 +89,75 @@ describe('useSidebarStore', () => {
})
})

describe('breadcrumbSegments', () => {
it('returns empty array when no root path', () => {
const store = useSidebarStore()

expect(store.breadcrumbSegments).toEqual([])
})

it('parses absolute path into segments', () => {
const store = useSidebarStore()

store.rootPath = '/Users/gautambanerjee/projects/Leaf'

expect(store.breadcrumbSegments).toEqual([
{ name: '~', path: '/Users/gautambanerjee' },
{ name: 'projects', path: '/Users/gautambanerjee/projects' },
{ name: 'Leaf', path: '/Users/gautambanerjee/projects/Leaf' },
])
})

it('uses ~ shorthand for home directory on macOS', () => {
const store = useSidebarStore()

store.rootPath = '/Users/testuser/Documents'

expect(store.breadcrumbSegments[0]).toEqual({
name: '~',
path: '/Users/testuser',
})
expect(store.breadcrumbSegments[1]).toEqual({
name: 'Documents',
path: '/Users/testuser/Documents',
})
})

it('uses ~ shorthand for home directory on Linux', () => {
const store = useSidebarStore()

store.rootPath = '/home/user/code/project'

expect(store.breadcrumbSegments).toEqual([
{ name: '~', path: '/home/user' },
{ name: 'code', path: '/home/user/code' },
{ name: 'project', path: '/home/user/code/project' },
])
})

it('handles path without home directory prefix', () => {
const store = useSidebarStore()

store.rootPath = '/opt/data/myfiles'

expect(store.breadcrumbSegments).toEqual([
{ name: 'opt', path: '/opt' },
{ name: 'data', path: '/opt/data' },
{ name: 'myfiles', path: '/opt/data/myfiles' },
])
})

it('handles home directory root itself', () => {
const store = useSidebarStore()

store.rootPath = '/Users/testuser'

expect(store.breadcrumbSegments).toEqual([{ name: '~', path: '/Users/testuser' }])
})
})

describe('closeFolder', () => {
it('resets state and hides sidebar', () => {
it('resets folder state but does not hide sidebar', () => {
const store = useSidebarStore()

// Simulate an open folder state
Expand All @@ -99,7 +170,32 @@ describe('useSidebarStore', () => {
expect(store.fileTree).toBeNull()
expect(store.rootPath).toBeNull()
expect(store.error).toBeNull()
expect(store.visible).toBe(false)
// Sidebar stays visible — user can open another folder
expect(store.visible).toBe(true)
})
})

describe('navigateToFolder', () => {
it('calls openFolder with the given path', async () => {
const store = useSidebarStore()

const mockTree = {
name: 'projects',
path: '/Users/test/projects',
is_dir: true,
children: [],
}
mockedInvoke.mockResolvedValueOnce(mockTree)

await store.navigateToFolder('/Users/test/projects')

expect(mockedInvoke).toHaveBeenCalledWith('read_directory_tree', {
path: '/Users/test/projects',
maxDepth: 3,
})
expect(store.rootPath).toBe('/Users/test/projects')
expect(store.fileTree).toEqual(mockTree)
expect(store.visible).toBe(true)
})
})
})
105 changes: 98 additions & 7 deletions src/components/sidebar/Sidebar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,8 @@ const sidebarStyle = computed(() => ({
/>
</svg>
</button>
<!-- Close folder button -->
<button
v-if="sidebar.hasFolderOpen && sidebar.activePanel === 'files'"
class="sidebar-btn"
title="Close folder"
@click="sidebar.closeFolder()"
>
<!-- Hide sidebar button (does NOT clear folder) -->
<button class="sidebar-btn" title="Hide sidebar" @click="sidebar.hideSidebar()">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path
d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"
Expand Down Expand Up @@ -126,6 +121,39 @@ const sidebarStyle = computed(() => ({
</div>
</div>

<!-- Breadcrumb navigation -->
<div v-if="sidebar.hasFolderOpen && sidebar.activePanel === 'files'" class="breadcrumb-bar">
<div class="breadcrumb-segments">
<template v-for="(segment, index) in sidebar.breadcrumbSegments" :key="segment.path">
<span v-if="index > 0" class="breadcrumb-separator">/</span>
<button
v-if="index < sidebar.breadcrumbSegments.length - 1"
class="breadcrumb-btn"
:title="segment.path"
@click="sidebar.navigateToFolder(segment.path)"
>
{{ segment.name }}
</button>
<span v-else class="breadcrumb-current" :title="segment.path">
{{ segment.name }}
</span>
</template>
</div>
<!-- Close folder (eject) button -->
<button
class="sidebar-btn breadcrumb-close"
title="Close folder"
@click="sidebar.closeFolder()"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
<path
d="M8 1a.5.5 0 0 1 .5.5v4.793l2.146-2.147a.5.5 0 0 1 .708.708l-3 3a.5.5 0 0 1-.708 0l-3-3a.5.5 0 1 1 .708-.708L7.5 6.293V1.5A.5.5 0 0 1 8 1z"
/>
<path d="M3 13.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5z" />
</svg>
</button>
</div>

<!-- Sidebar content -->
<div class="sidebar-content">
<!-- AI Files panel -->
Expand Down Expand Up @@ -263,6 +291,69 @@ const sidebarStyle = computed(() => ({
color: var(--sidebar-btn-hover-color, #333);
}

.breadcrumb-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-bottom: 1px solid var(--sidebar-border, #e0e0e0);
flex-shrink: 0;
min-height: 24px;
}

.breadcrumb-segments {
display: flex;
align-items: center;
gap: 2px;
overflow-x: auto;
white-space: nowrap;
flex: 1;
min-width: 0;
font-size: 0.8em;
scrollbar-width: none;
}

.breadcrumb-segments::-webkit-scrollbar {
display: none;
}

.breadcrumb-separator {
color: var(--text-secondary, #999);
opacity: 0.6;
flex-shrink: 0;
user-select: none;
}

.breadcrumb-btn {
border: none;
background: none;
padding: 1px 2px;
cursor: pointer;
color: var(--text-secondary, #999);
font-size: inherit;
font-family: inherit;
border-radius: 2px;
flex-shrink: 0;
}

.breadcrumb-btn:hover {
text-decoration: underline;
color: var(--sidebar-text-color, #666);
}

.breadcrumb-current {
color: var(--sidebar-text-color, #333);
font-weight: 600;
flex-shrink: 0;
padding: 1px 2px;
}

.breadcrumb-close {
width: 20px;
height: 20px;
flex-shrink: 0;
}

.sidebar-content {
flex: 1;
overflow-y: auto;
Expand Down
Loading
Loading