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.
- Swift 6.1+
- macOS 13+
- iOS 16+
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.
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 ./docsThe docs/ output is generated and should not be committed; GitHub Actions uploads it directly as
a Pages artifact.
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.
Build, stage, and launch it as a foreground app bundle from the repository root:
./script/build_and_run.shUse ./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.
Build the hierarchy once, then keep the model alive for as long as the tree is mounted:
import TreeKit
letmodel=tryFileTreeModel<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:
letmodel=tryFileTreeModel<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.
FileTree uses the built-in file/folder row when its model contains FileTreePath values:
structProjectSidebar:View{letmodel:FileTreeModel<FileTreePath>varbody:someView{FileTree(model: model){ item inopen(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 inopen(item.path)}){ item, context inHStack(spacing:6){Image(systemName: item.kind ==.directory ?"folder":"doc")Text(item.name)Spacer()iflet status =gitStatus[item.id]{Text(status.label).foregroundStyle(status.color)}}.opacity(context.isSelected ?1:0.92)}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:
varconfiguration=FileTreeConfiguration()
configuration.icons =.standard // .minimal, .complete, or .none
configuration.icons.colored =falseApplications 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.
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:
lettreeView=FileTreeView(model: model)
treeView.onActivate ={ item inopen(item.path)}Native clients can provide reusable row views directly:
lettreeView=FileTreeView(model: model){ item, context, reusableView in#if canImport(AppKit)letlabel=(reusableView as?NSTextField)??NSTextField(labelWithString:"")
label.stringValue = item.name
return label
#elseletlabel=(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.
TreeKit does not require filesystem paths. Any Identifiable forest can be prepared while
preserving caller-provided root and sibling order:
structProjectNode:Identifiable{letid:UUIDlettitle:Stringvarchildren:[ProjectNode]}letprepared=tryPreparedTree(roots: roots, children: \ProjectNode.children)letmodel=FileTreeModel(prepared, initialExpansion:.collapsed)Identifiers must be globally unique and stable. Duplicate identifiers and cycles are rejected before a model is created.
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.
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:
letselectionSubscription= model.selectionChanges.sink{ selection inupdateInspector(selection)}letfocusSubscription= model.focusChanges.sink{ focusedID inupdateCommandTarget(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.
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:
letmutationSubscription= model.onMutation{ event inpersist(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.
Configure caller policy once, then start from a canonical ID or the focused row:
model.configureRenaming(.init(
canRename:{ !protectedPaths.contains($0.id)},
onRename:{ event inpersist(event)},
onError:{ error inshow(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.
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 inpersist(event.moves)},
onDropError:{ failure inshow(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.
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:
.expandMatchespreserves current expansion and additionally expands every match path..collapseNonMatchesstarts 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:
letmodel=FileTreeModel(
prepared,
searchText:{ $0.title })PreparedTreeindexes 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.

