Skip to content
Open
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
27 changes: 27 additions & 0 deletions .github/workflows/performance-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: File Browser Performance Tests

on:
push:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
performance-tests:
runs-on: macos-26
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false

- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_26.2.app || sudo xcode-select -s /Applications/Xcode.app

- name: Run file browser tests
run: |
chmod +x run-tests.sh run-ui-tests.sh
./run-tests.sh
./run-ui-tests.sh
146 changes: 10 additions & 136 deletions Sources/mindle/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ struct ContentView: View {
}
HSplitView {
if store.showFileBrowser {
FileBrowserSidebar()
FileBrowserSidebar(
browser: store.fileBrowser,
theme: store.theme,
onRefresh: store.refreshFileTree,
onOpen: store.open
)
.frame(minWidth: 200, idealWidth: 260, maxWidth: 400)
}
ReaderPane()
Expand All @@ -49,7 +54,7 @@ struct ContentView: View {
withAnimation(.easeInOut(duration: 0.18)) {
store.showFileBrowser.toggle()
}
if store.showFileBrowser && store.fileTree == nil {
if store.showFileBrowser && store.fileBrowser.tree == nil {
store.refreshFileTree()
}
} label: {
Expand Down Expand Up @@ -146,6 +151,9 @@ struct ContentView: View {
}
return false
}
.onDisappear {
store.fileBrowser.cancelAll()
}
}

private func themeIcon(_ t: ReaderTheme) -> String {
Expand Down Expand Up @@ -1284,140 +1292,6 @@ struct AnnotationMessageRow: View {
}()
}

// MARK: - File browser sidebar

struct FileBrowserSidebar: View {
@EnvironmentObject var store: DocumentStore

var body: some View {
let c = store.theme.colors
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: 8) {
Image(systemName: "folder")
.foregroundStyle(c.accent)
Text("Files")
.font(.system(size: 13, weight: .semibold, design: .serif))
.foregroundStyle(c.text)
Spacer()
Button {
store.refreshFileTree()
} label: {
Image(systemName: "arrow.clockwise")
.font(.system(size: 11))
}
.buttonStyle(.plain)
.foregroundStyle(c.muted)
.help("Refresh file list")
}
.padding(.horizontal, 16)
.padding(.vertical, 12)

Rectangle().fill(c.rule.opacity(0.4)).frame(height: 0.5)

if let tree = store.fileTree, let children = tree.children, !children.isEmpty {
ScrollView {
// Non-lazy VStack so the tree's content size stays constant
// when other window state changes (e.g. fileURL flipping
// isCurrent on a row, or the TabBar appearing/disappearing
// as tabs.count crosses the 2-to-1 boundary). LazyVStack
// re-measured rows on those events and could nudge the
// scroll position, making the active row appear to shift
// (#36). The directories Mindle browses are typically
// small enough that eager realization is fine.
VStack(alignment: .leading, spacing: 0) {
ForEach(children) { child in
FileTreeRow(node: child, depth: 0)
}
}
.padding(.vertical, 6)
}
} else {
VStack(spacing: 8) {
Image(systemName: "tray")
.font(.system(size: 28, weight: .ultraLight))
.foregroundStyle(c.muted.opacity(0.7))
Text("No markdown files\nin this directory.")
.multilineTextAlignment(.center)
.font(.system(size: 12, design: .serif).italic())
.foregroundStyle(c.muted)
.padding(.horizontal, 24)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.background(c.sidebar)
}
}

struct FileTreeRow: View {
let node: FileNode
let depth: Int
@EnvironmentObject var store: DocumentStore
@State private var isExpanded: Bool = true

var body: some View {
let c = store.theme.colors
if node.isDirectory {
Button {
withAnimation(.easeInOut(duration: 0.12)) { isExpanded.toggle() }
} label: {
HStack(spacing: 6) {
Image(systemName: isExpanded ? "chevron.down" : "chevron.right")
.font(.system(size: 9, weight: .semibold))
.foregroundStyle(c.muted)
.frame(width: 10)
Image(systemName: "folder")
.font(.system(size: 11))
.foregroundStyle(c.muted)
Text(node.name)
.font(.system(size: 12, weight: .medium, design: .serif))
.foregroundStyle(c.text)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
}
.padding(.leading, CGFloat(depth) * 14 + 8)
.padding(.trailing, 10)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)

if isExpanded {
ForEach(node.children ?? []) { child in
FileTreeRow(node: child, depth: depth + 1)
}
}
} else {
let isCurrent = store.fileURL?.standardizedFileURL == node.url.standardizedFileURL
Button {
store.open(url: node.url)
} label: {
HStack(spacing: 6) {
Spacer().frame(width: 10)
Image(systemName: "doc.text")
.font(.system(size: 11))
.foregroundStyle(isCurrent ? c.accent : c.muted)
Text(node.name)
.font(.system(size: 12, design: .serif))
.foregroundStyle(c.text)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
}
.padding(.leading, CGFloat(depth) * 14 + 8)
.padding(.trailing, 10)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isCurrent ? c.accent.opacity(0.14) : Color.clear)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
}

// MARK: - Tab bar

struct TabBar: View {
Expand Down
86 changes: 35 additions & 51 deletions Sources/mindle/DocumentStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -169,14 +169,6 @@ enum AnnotationStatus: String, Codable {
case open, resolved, wontfix
}

struct FileNode: Identifiable, Equatable {
var id: URL { url }
let url: URL
let name: String
let isDirectory: Bool
let children: [FileNode]? // nil = leaf file; non-nil = directory
}

/// One open document inside a window. Active-tab state still lives in
/// the window-scoped @Published vars (`fileURL`, `rawText`, `annotations`,
/// `lastSyncedText`) so all existing features keep working untouched;
Expand Down Expand Up @@ -300,7 +292,7 @@ final class DocumentStore: ObservableObject {
}
@Published var showAnnotations: Bool = false
@Published var showFileBrowser: Bool = false
@Published var fileTree: FileNode? = nil
let fileBrowser = FileBrowserState()

// Tabs (per-window). Empty when no document is open; otherwise the active
// tab's state mirrors `fileURL` / `rawText` / `annotations` above.
Expand Down Expand Up @@ -522,8 +514,12 @@ final class DocumentStore: ObservableObject {
// the markdown pipeline (markdown-it, search, highlight, diff)
// doesn't try to do anything with the binary PDF bytes.
let kind = DocumentKind.kind(for: url)
let text: String = (kind == .pdf) ? "" : try String(contentsOf: url, encoding: .utf8)
finishOpen(url: url, text: text, kind: kind, sourceURL: nil, remoteTarget: nil)
let text: String = try PerformanceTrace.measure("LocalFileRead") {
(kind == .pdf) ? "" : try String(contentsOf: url, encoding: .utf8)
}
PerformanceTrace.measure("FileOpenApply") {
finishOpen(url: url, text: text, kind: kind, sourceURL: nil, remoteTarget: nil)
}
} catch {
NSSound.beep()
}
Expand All @@ -537,8 +533,8 @@ final class DocumentStore: ObservableObject {
// Re-root the file tree only when the new file is outside the current scope.
// Clicking a file inside a subfolder of the current root must preserve rooting.
let shouldRebuildTree: Bool
if let root = fileTree?.url {
shouldRebuildTree = !Self.isDescendant(url: url, of: root)
if let root = fileBrowser.rootURL {
shouldRebuildTree = !FileTreeBuilder.isDescendant(url, of: root)
} else {
shouldRebuildTree = true
}
Expand Down Expand Up @@ -571,7 +567,10 @@ final class DocumentStore: ObservableObject {
// Capture the sidecar-loaded annotations into the tab snapshot.
snapshotActiveTab()

if shouldRebuildTree { refreshFileTree() }
if shouldRebuildTree {
fileBrowser.setRoot(url.deletingLastPathComponent())
}
syncFileBrowserSelection(for: url)
if url.isFileURL && remoteTarget == nil {
NSDocumentController.shared.noteNewRecentDocumentURL(url)
}
Expand Down Expand Up @@ -860,6 +859,7 @@ final class DocumentStore: ObservableObject {
lastSyncedText = placeholder
annotations = []
collaborators = [:]
syncFileBrowserSelection(for: url)
closeSearch()
focusedAnnotation = nil
editingAnnotationID = nil
Expand Down Expand Up @@ -924,6 +924,7 @@ final class DocumentStore: ObservableObject {
lastSyncedText = raw
annotations = []
collaborators = [:]
syncFileBrowserSelection(for: url)
closeSearch()
focusedAnnotation = nil
editingAnnotationID = nil
Expand Down Expand Up @@ -1098,6 +1099,7 @@ final class DocumentStore: ObservableObject {
// Last tab closed — back to empty state.
activeTabID = nil
fileURL = nil
syncFileBrowserSelection(for: nil)
activeRemoteTarget = nil
rawText = ""
lastSyncedText = ""
Expand All @@ -1124,6 +1126,7 @@ final class DocumentStore: ObservableObject {

private func loadTabState(_ tab: DocumentTab) {
fileURL = tab.fileURL
syncFileBrowserSelection(for: tab.fileURL)
self.activeRemoteTarget = tab.sourceURL.flatMap { SSHTarget(sourceURL: $0) }
rawText = tab.rawText
lastSyncedText = tab.lastSyncedText
Expand Down Expand Up @@ -1172,48 +1175,29 @@ final class DocumentStore: ObservableObject {

// MARK: - File browser

static let browsableExtensions: Set<String> = ["md", "markdown", "mdown", "mkd", "txt", "pdf"]

func refreshFileTree() {
guard let url = fileURL else { fileTree = nil; return }
fileTree = Self.buildTree(at: url.deletingLastPathComponent())
}

private static func isDescendant(url: URL, of ancestor: URL) -> Bool {
let aPath = ancestor.standardizedFileURL.path
let uPath = url.standardizedFileURL.path
let prefix = aPath.hasSuffix("/") ? aPath : aPath + "/"
return uPath.hasPrefix(prefix)
}

private static func buildTree(at dir: URL) -> FileNode? {
let fm = FileManager.default
guard let entries = try? fm.contentsOfDirectory(
at: dir,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
) else {
return FileNode(url: dir, name: dir.lastPathComponent, isDirectory: true, children: [])
guard let url = fileURL, url.isFileURL else {
fileBrowser.setRoot(nil)
return
}

var children: [FileNode] = []
for entry in entries {
let isDir = (try? entry.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
if isDir {
if let sub = buildTree(at: entry), !(sub.children ?? []).isEmpty {
children.append(sub)
}
} else if browsableExtensions.contains(entry.pathExtension.lowercased()) {
children.append(FileNode(url: entry, name: entry.lastPathComponent, isDirectory: false, children: nil))
}
let root = fileBrowser.rootURL
if let root, FileTreeBuilder.isDescendant(url, of: root) {
fileBrowser.refresh()
} else {
fileBrowser.setRoot(url.deletingLastPathComponent())
}
syncFileBrowserSelection(for: url)
}

children.sort { a, b in
if a.isDirectory != b.isDirectory { return a.isDirectory }
return a.name.localizedCaseInsensitiveCompare(b.name) == .orderedAscending
private func syncFileBrowserSelection(for url: URL?) {
guard let url,
url.isFileURL,
let root = fileBrowser.rootURL,
FileTreeBuilder.isDescendant(url, of: root) else {
fileBrowser.setSelectedURL(nil)
return
}

return FileNode(url: dir, name: dir.lastPathComponent, isDirectory: true, children: children)
fileBrowser.setSelectedURL(url)
}

func toggleTheme() {
Expand Down
Loading