Skip to content

Repository files navigation

TreeKit

TreeKit is a model-first file-tree rendering component for SwiftUI, AppKit, and UIKit. It keeps hierarchy work out of row views, uses stable identities for state, and delegates viewport reuse, keyboard behavior, and accessibility to native platform controls.

Requirements

  • Swift 6.1+
  • macOS 13+
  • iOS 16+

Installation

Add the package repository in Xcode, or declare the released package in Package.swift:

dependencies: [
    .package(
        url: "https://github.com/RayZhao1998/TreeKit.git",
        from: "1.1.0"
    )
],
targets: [
    .target(
        name: "YourTarget",
        dependencies: [
            .product(name: "TreeKit", package: "TreeKit")
        ]
    )
]

In Xcode, choose File → Add Package Dependencies, enter https://github.com/RayZhao1998/TreeKit.git, select Up to Next Major Version starting at 1.1.0, and add the TreeKit library product to your target.

Documentation

TreeKit's API reference and guides live in Sources/TreeKit/TreeKit.docc. The repository includes a GitHub Pages workflow at .github/workflows/documentation.yml; after Pages is configured to use GitHub Actions as its source, every push to main can regenerate and deploy the site to:

https://rayzhao1998.github.io/TreeKit/documentation/treekit/

Generate the same static site locally with:

swift package --allow-writing-to-directory ./docs \
    generate-documentation --target TreeKit \
    --disable-indexing \
    --transform-for-static-hosting \
    --hosting-base-path TreeKit \
    --output-path ./docs

The docs/ output is generated and should not be committed; GitHub Actions uploads it directly as a Pages artifact.

Demo

Demo/ is a standalone macOS SwiftPM app that depends on the package through a local path. It shows a custom SwiftUI FileTree and the native AppKit FileTreeView using the same model. Its bundled fixture contains all 2,188 changed files from oven-sh/bun PR #30412, including added, modified, deleted, and renamed paths.

TreeKit Demo file tree in Light Mode TreeKit Demo file tree in Dark Mode

Build, stage, and launch it as a foreground app bundle from the repository root:

./script/build_and_run.sh

Use ./script/build_and_run.sh --verify to launch and confirm the process. The included .codex/environments/environment.toml exposes the same command as the Codex Run action. The reproducible stress workload and measured CPU, memory, and effective update-rate baseline are documented in Docs/Performance.md.

Path-first input

Build the hierarchy once, then keep the model alive for as long as the tree is mounted:

import TreeKit

let model = try FileTreeModel<FileTreePath>(
    paths: [
        "README.md",
        "Sources/TreeKit/FileTreeModel.swift",
        "Sources/TreeKit/SwiftUI/FileTree.swift",
        "Tests/TreeKitTests/PreparedTreeTests.swift"
    ],
    initialExpansion: .depth(1)
)

Paths are normalized and use / separators. Directory identities have a trailing /, so the directory Sources is selected and expanded with the stable ID Sources/. Missing ancestors are synthesized. Inputs must be relative; absolute paths and .. traversal are rejected. The default ordering is folders first, then lexicographic by component name.

Call prepareFileTree(paths:options:) directly when preparation and model construction happen at different layers.

Directory-only chains can be projected as one row without changing canonical paths:

let model = try FileTreeModel<FileTreePath>(
    paths: paths,
    options: .init(flattenEmptyDirectories: true)
)

model.setFlattenEmptyDirectories(false) // Toggle the projection at runtime.

The terminal directory owns selection, focus, disclosure, and activation for the combined row. Custom rows receive every represented component through context.segments and can render context.displayedPathSegments.joined(separator: " / "). Search, reset, and incremental path mutations rebuild the flattened projection while preserving canonical identity state.

SwiftUI: FileTree

FileTree uses the built-in file/folder row when its model contains FileTreePath values:

struct ProjectSidebar: View {
    let model: FileTreeModel<FileTreePath>

    var body: some View {
        FileTree(model: model) { item in
            open(item.path)
        }
    }
}

The native tree subscribes to the model directly. When a SwiftUI container owns the model but does not render any of its published values, keep the reference in @State instead of observing it from that whole container. Put counters, selection details, and other model-driven UI in small @ObservedObject leaf views. This prevents a selection or reveal from needlessly updating the entire surrounding layout and reconfiguring every mounted custom row.

Supply a row builder to replace only the row content. TreeKit still owns disclosure geometry, indentation, selection hit testing, keyboard behavior, and reuse:

FileTree(model: model, onActivate: { item in
    open(item.path)
}) { item, context in
    HStack(spacing: 6) {
        Image(systemName: item.kind == .directory ? "folder" : "doc")
        Text(item.name)
        Spacer()
        if let status = gitStatus[item.id] {
            Text(status.label)
                .foregroundStyle(status.color)
        }
    }
    .opacity(context.isSelected ? 1 : 0.92)
}

File-type icons

The default SwiftUI, AppKit, and UIKit rows resolve file-type icons from the vendored pierrecomputer/vscode-icons catalog. The complete colored set is enabled by default. Select a smaller monochrome set or disable built-in file mappings through the shared configuration:

var configuration = FileTreeConfiguration()
configuration.icons = .standard // .minimal, .complete, or .none
configuration.icons.colored = false

Applications can override structural icons and match exact basenames, basename substrings, or multi-part extensions without replacing the row:

configuration.icons.remap[.folder] = .systemSymbol("folder.fill")
configuration.icons.byFileName["Package.swift"] = .systemSymbol("shippingbox")
configuration.icons.byFileNameContains[".generated."] = .systemSymbol("gearshape")
configuration.icons.byFileExtension["spec.ts"] = .builtIn("react")
configuration.icons.byFileExtension["pdf"] = .asset("ProductPDFIcon")

Keys are case-insensitive. Resolution precedence is exact basename, longest matching basename substring, longest extension suffix, built-in mapping, then the generic file icon. Custom SwiftUI rows can render the same result with FileTreeIconImage; native code can call configuration.icons.image(for:isExpanded:). The original SVG resources, license, and pinned upstream revision are recorded in THIRD_PARTY_NOTICES.md.

AppKit and UIKit: FileTreeView

The native name is the same on both platforms. Conditional compilation selects an NSView subclass backed by NSOutlineView on macOS and a UIView subclass backed by UICollectionView on iOS.

For FileTreePath, the default native row is available without a provider:

let treeView = FileTreeView(model: model)
treeView.onActivate = { item in
    open(item.path)
}

Native clients can provide reusable row views directly:

let treeView = FileTreeView(model: model) { item, context, reusableView in
#if canImport(AppKit)
    let label = (reusableView as? NSTextField) ?? NSTextField(labelWithString: "")
    label.stringValue = item.name
    return label
#else
    let label = (reusableView as? UILabel) ?? UILabel()
    label.text = item.name
    return label
#endif
}

FileTreeRowContext exposes stable identity, depth, sibling position, expansion, selection, and focus state. Call reloadRows(withIDs:) after caller-owned decoration data changes without rebuilding the hierarchy.

Arbitrary node types

TreeKit does not require filesystem paths. Any Identifiable forest can be prepared while preserving caller-provided root and sibling order:

struct ProjectNode: Identifiable {
    let id: UUID
    let title: String
    var children: [ProjectNode]
}

let prepared = try PreparedTree(roots: roots, children: \ProjectNode.children)
let model = FileTreeModel(prepared, initialExpansion: .collapsed)

Identifiers must be globally unique and stable. Duplicate identifiers and cycles are rejected before a model is created.

Model operations

FileTreeModel is the shared state boundary for every renderer:

model.select(id)
model.toggleSelection(of: id)
model.expand(id)
model.collapse(id)
model.toggleExpansion(of: id)
model.reveal(id, select: true, position: .center)
model.reset(nextPreparedTree)

Selection and expansion survive reset for retained identities by default. Removed identities are pruned atomically before the mounted renderer observes the new data.

Visible navigation and interaction observation

Focus and selection are related but independent. Command handlers can move focus through the current visible projection without selecting rows or reading native row indexes:

model.focusFirstItem()
model.focusNextItem()
model.focusPreviousItem()
model.focusParentItem()
model.focusLastItem()
model.focusNearestItem(to: preferredID)

model.scrollTo(id, position: .center, focus: true) // Does not select.
model.scrollTo(id, focus: false)                   // Scroll only.

Traversal follows the active expansion and search projection. Next and previous clamp at the visible boundaries. Nearest focus chooses the requested visible row, its closest visible ancestor, or the last retained visible position when an item was removed. Scroll and reveal requests for an identity excluded by the active search projection are ignored without changing focus or selection.

Sibling SwiftUI, AppKit, and UIKit state can subscribe without observing unrelated revisions:

let selectionSubscription = model.selectionChanges.sink { selection in
    updateInspector(selection)
}
let focusSubscription = model.focusChanges.sink { focusedID in
    updateCommandTarget(focusedID)
}

Both publishers emit their current value on subscription and then only distinct changes. Native pointer and keyboard interactions write through the same model state as programmatic navigation.

Incremental path mutations

FileTreeModel<FileTreePath> also exposes the path-first mutation vocabulary used by @pierre/trees. Every successful call installs a complete model transaction before emitting its typed semantic event:

let mutationSubscription = model.onMutation { event in
    persist(event) // Retain this cancellable with the surrounding controller.
}

try model.add("Sources/TreeKit/NewRow.swift")
try model.remove("Tests/ObsoleteTests.swift")
try model.move("Sources/Old/", to: "Sources/New/")

try model.batch([
    .add(path: "Sources/Feature.swift"),
    .move(from: "README.md", to: "Docs/README.md"),
    .remove(path: "Legacy/")
])

try model.resetPaths(nextPaths)

add, remove, move, batch, and resetPaths normalize and validate paths before changing the mounted tree. A batch is ordered and atomic: if any operation fails, the model publishes no revision or mutation event. Moving a directory remaps valid selection, expansion, and focus IDs; removals prune identities inside the removed subtree. The destination parent of a move must already be a directory, and directory destinations retain the trailing / convention.

Subscribe through mutationEvents, or use onMutation(_:handler:) to filter by FileTreePathMutationEvent.Kind. These events report in-memory intent for persistence, logging, or adjacent UI. TreeKit never creates, deletes, or moves filesystem entries; the caller owns that side effect and any rollback policy.

Inline rename

Configure caller policy once, then start from a canonical ID or the focused row:

model.configureRenaming(.init(
    canRename: { !protectedPaths.contains($0.id) },
    onRename: { event in persist(event) },
    onError: { error in show(error) }
))

try model.startRenaming("Sources/Old.swift")
// The native row editor commits with Return and cancels with Escape.

commitRenaming(_:) validates a single same-parent component, rejects duplicate destinations, and completes through the existing move transaction. cancelRenaming() leaves the hierarchy unchanged. AppKit, UIKit, and SwiftUI-hosted custom rows share the same native editor and focus restoration. renameEvents reports canonical source, destination, and item kind.

TreeKit updates only its in-memory hierarchy. Callers own filesystem persistence, authorization, rollback, and how renameError is presented to people.

Native drag and drop

AppKit and UIKit renderers enable native previews, drop indicators, autoscroll, and delayed folder expansion. SwiftUI receives the same behavior through its native host. Configure path policy once:

model.configureDragAndDrop(.init(
    canDrag: { paths in !paths.contains { protectedPaths.contains($0.id) } },
    canDrop: { proposal in policy.accepts(proposal) },
    onDropComplete: { event in persist(event.moves) },
    onDropError: { failure in show(failure.error) }
))

Targets use before, after, or inside. Before/after resolve to the target's parent; inside requires a directory, while inside with a nil target means the forest root. Multi-selection drags exclude redundant descendants of selected directories. Successful drops validate all destinations first, then install one path-mutation transaction and emit typed completion events; failed requests emit typed failure events without changing the tree. Exact sibling reordering uses .inputOrder; sorted models reapply their chosen sort policy. Native sessions are scoped to their originating model so two mounted trees cannot mutate one another accidentally.

TreeKit owns only in-memory intent. Callers still own filesystem moves, authorization, persistence, rollback, external drag formats, and error presentation.

Model-backed search

Search is shared FileTreeModel state, so SwiftUI, AppKit, and UIKit always render the same projection. Queries are trimmed, normalized to / separators, and matched case-insensitively against canonical paths for FileTreePath models:

model.openSearch(initialQuery: "sources\\treekit")

model.searchQuery       // "sources/treekit"
model.matchingIDs       // stable IDs in prepared preorder
model.isSearchOpen      // true

model.focusNextSearchMatch()
model.focusPreviousSearchMatch()
model.setSearchQuery("filetreeview")
model.closeSearch()

FileTreeSearchMode controls only the effective visible projection. Canonical selection and expansion remain identity-based and are not rewritten when a query changes:

  • .expandMatches preserves current expansion and additionally expands every match path.
  • .collapseNonMatches starts from a collapsed projection and expands match paths, retaining nonmatching siblings as context.
  • .hideNonMatches—the default—shows only matches and the ancestors required to preserve their hierarchy.

An open search with an empty query renders the normal expansion projection. In .hideNonMatches, a nonempty query with no matches renders an empty tree. Custom rows can use context.isSearchMatch for highlighting without recomputing the match.

For arbitrary node types, provide the searchable text once when the model is created:

let model = FileTreeModel(
    prepared,
    searchText: { $0.title }
)

Performance contract

  • PreparedTree indexes nodes, parents, ordered children, depth, and siblings in O(n) time and memory. It stores child arrays only for branches and derives sibling counts instead of retaining a redundant per-node index.
  • Identity lookup and direct selection changes use hash indexes.
  • Expanding or collapsing computes only the affected subtree, mutates one contiguous range, and refreshes shifted identity indexes. Complete resets and expansion-set replacements rebuild the visible projection once. Revealing a path expands all missing ancestors and inserts the newly visible branch in one projection update instead of rebuilding every visible row.
  • AppKit and UIKit render only native mounted cells. SwiftUI custom content is hosted inside those reused cells rather than recursively constructing the entire tree.
  • Row height is fixed by FileTreeConfiguration, avoiding whole-tree measurement during scroll.
  • Search caches normalized node text once, preserves deterministic prepared preorder, and rebuilds only the shared visible projection when its query or mode changes.
  • Path mutations stage validation away from mounted state, then install one prepared hierarchy and visible projection. Batches may validate ordered intermediate hierarchies, but publish only the final projection and one semantic event.
  • Drag/drop resolves and validates canonical sources and destinations before mutation, then installs one shared path transaction. Native hover checks do not build a second renderer-owned hierarchy.

The package intentionally does not enumerate the filesystem, watch directories, or persist state. The current 1.x model renders an already known hierarchy and can update it through path-first mutations or complete reset. A compatible lazy-child design for much larger trees is described in Docs/LazyLoading.md; it is a roadmap, not a currently shipped API.

Design references

About

A fast, flexible and open source file tree component for SwiftUI, AppKit, and UIKit, inspired by pierre/trees.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages