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
40 changes: 36 additions & 4 deletions buildFsTree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,53 @@
type: 'file' | 'directory'
loc?: number
mtime?: number
symlink?: boolean
target?: string
children?: TreeNode[]
}

export const IGNORE = new Set(['.git', 'node_modules', 'dist', 'dist-lib', '.DS_Store'])
export const SOURCE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx'])

export function buildTree(dirPath: string): TreeNode[] {
export function buildTree(dirPath: string, visited: Set<string> = new Set()): TreeNode[] {

Check warning on line 18 in buildFsTree.ts

View workflow job for this annotation

GitHub Actions / lint

Function 'buildTree' has a complexity of 21. Maximum allowed is 10
const entries = fs.readdirSync(dirPath, { withFileTypes: true })
const nodes: TreeNode[] = []

for (const entry of entries) {
if (IGNORE.has(entry.name)) continue
const fullPath = path.join(dirPath, entry.name)
const isSymlink = entry.isSymbolicLink()
let resolvedType: 'file' | 'directory'
let target: string | undefined
let realPath: string | undefined
if (isSymlink) {
try {
target = fs.readlinkSync(fullPath)
realPath = fs.realpathSync(fullPath)
resolvedType = fs.statSync(fullPath).isDirectory() ? 'directory' : 'file'
} catch { continue }
} else {
resolvedType = entry.isDirectory() ? 'directory' : 'file'
}
let mtime: number | undefined
try { mtime = fs.statSync(fullPath).mtimeMs } catch { /* stat unavailable */ }
if (entry.isDirectory()) {
if (resolvedType === 'directory') {
let children: TreeNode[] = []
if (isSymlink) {
if (realPath && !visited.has(realPath)) {
const next = new Set(visited); next.add(realPath)
children = buildTree(fullPath, next)
}
} else {
children = buildTree(fullPath, visited)
}
nodes.push({
id: fullPath,
name: entry.name,
type: 'directory',
...(mtime != null && { mtime }),
children: buildTree(fullPath),
...(isSymlink && { symlink: true, target }),
children,
})
} else {
const ext = path.extname(entry.name)
Expand All @@ -37,7 +62,14 @@
if (isText) {
try { loc = fs.readFileSync(fullPath, 'utf-8').split('\n').length } catch { /* binary or unreadable */ }
}
nodes.push({ id: fullPath, name: entry.name, type: 'file', ...(loc != null && { loc }), ...(mtime != null && { mtime }) })
nodes.push({
id: fullPath,
name: entry.name,
type: 'file',
...(loc != null && { loc }),
...(mtime != null && { mtime }),
...(isSymlink && { symlink: true, target }),
})
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/entities/file/ui/FileTreeTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react'
import type { NormalizedData } from '@os/store/types'
import { TreeTable } from '@os/ui/TreeTable'
import { FileIcon } from '@os/ui/FileIcon'
import { SymlinkIndicator } from '@os/ui/indicators'
import { ax } from '@styles/ax'

export type FileTableSortKey = 'name' | 'kind' | 'date' | 'loc'
Expand Down Expand Up @@ -49,12 +50,15 @@ const renderCell = (
): React.ReactNode => {
const name = (data?.name as string) ?? ''
const type = (data?.type as string) ?? 'file'
const symlink = Boolean(data?.symlink)
const target = data?.target as string | undefined
switch (col.key) {
case 'name':
return (
<>
<FileIcon name={name} type={type} expanded={state.expanded} />
<span className={ax({ clamp: '1' })}>{name}</span>
{symlink && <SymlinkIndicator target={target} className={dimCell} />}
</>
)
case 'kind':
Expand Down
16 changes: 16 additions & 0 deletions src/interactive-os/ui/indicators/SymlinkIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { CornerDownRight } from 'lucide-react'

interface SymlinkIndicatorProps {
target?: string
className?: string
}

export function SymlinkIndicator({ target, className }: SymlinkIndicatorProps) {
return (
<CornerDownRight
size="1em"
className={className}
aria-label={target ? `symlink → ${target}` : 'symlink'}
/>
)
}
1 change: 1 addition & 0 deletions src/interactive-os/ui/indicators/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ export { AddIndicator } from './AddIndicator'
export { IncrementIndicator } from './IncrementIndicator'
export { FileTypeIndicator } from './FileTypeIndicator'
export { StarIndicator } from './StarIndicator'
export { SymlinkIndicator } from './SymlinkIndicator'
3 changes: 2 additions & 1 deletion src/pages/finder/PageFinder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// @useState-hatch — sortKey/sortDir/filters: view preference; initialStore/loading: async tree fetch; quickOpenVisible: dismiss axis candidate; viewMode: view preference localStorage; currentRoot: sidebar selection; previewPath: follow-focus file preview
import { useState, useEffect, useCallback, useRef, useMemo, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import { Folder, Code2, BookText, Image, Boxes, CalendarDays, CalendarClock, Hash, Tag, Activity, Component } from 'lucide-react'
import { Folder, Code2, BookText, Image, Boxes, CalendarDays, CalendarClock, Hash, Tag, Activity, Component, Sparkles } from 'lucide-react'
import { AriaRoute } from '@os/primitives/AriaRoute'
import { defineRouteKey } from '@os/primitives/defineRouteKey'
import { FlatLayout } from '@os/ui/FlatLayout'
Expand Down Expand Up @@ -69,6 +69,7 @@ const FAVORITES: FavoriteRoot[] = [
{ id: 'root', name: '/', path: DEFAULT_ROOT, icon: <Folder size={ICON_SIZE} /> },
{ id: 'src', name: 'src', path: `${DEFAULT_ROOT}/src`, icon: <Code2 size={ICON_SIZE} /> },
{ id: 'docs', name: 'docs', path: `${DEFAULT_ROOT}/docs`, icon: <BookText size={ICON_SIZE} /> },
{ id: 'claude', name: '.claude', path: `${DEFAULT_ROOT}/.claude`, icon: <Sparkles size={ICON_SIZE} /> },
{ id: 'ui', name: 'UI Showcase', path: `${DEFAULT_ROOT}/contents/ui`, icon: <Component size={ICON_SIZE} /> },
{ id: 'screenshots', name: 'screenshots', path: `${DEFAULT_ROOT}/screenshots`, icon: <Image size={ICON_SIZE} /> },
]
Expand Down
2 changes: 2 additions & 0 deletions src/pages/finder/fsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export interface TreeNode {
type: 'file' | 'directory'
loc?: number
mtime?: number
symlink?: boolean
target?: string
children?: TreeNode[]
}

Expand Down
2 changes: 1 addition & 1 deletion src/pages/finder/treeTransform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function treeToStore(nodes: TreeNode[], titleMap?: Map<string, string>):
const ext = isFile && node.name.includes('.') ? node.name.split('.').pop() ?? '' : ''
const displayName = titleMap?.get(node.id) ?? node.name
const cells: unknown[] = [{ name: displayName, type: node.type }, ext, isFile ? (node.loc ?? '') : '']
entities[node.id] = { id: node.id, data: { name: displayName, type: node.type, path: node.id, ...(node.loc != null && { loc: node.loc }), ...(node.mtime != null && { mtime: node.mtime }), cells } }
entities[node.id] = { id: node.id, data: { name: displayName, type: node.type, path: node.id, ...(node.loc != null && { loc: node.loc }), ...(node.mtime != null && { mtime: node.mtime }), ...(node.symlink && { symlink: true, target: node.target }), cells } }
if (!relationships[parentId]) relationships[parentId] = []
relationships[parentId].push(node.id)
if (node.children && node.children.length > 0) {
Expand Down
2 changes: 2 additions & 0 deletions src/pages/finder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,6 @@ export type FileNodeData = {
type: 'file' | 'directory'
path: string
mtime?: number
symlink?: boolean
target?: string
}
Loading