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
25 changes: 24 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,28 @@ permissions:
contents: write

jobs:
tests:
permissions:
contents: read
runs-on: macos-26
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 logic and screenshot tests
run: |
chmod +x run-tests.sh run-screenshot-tests.sh
./run-tests.sh
./run-screenshot-tests.sh

build:
needs: tests
permissions:
contents: write
runs-on: macos-26
env:
# Non-secret mirror so `if:` conditions can gate on signing availability.
Expand All @@ -27,6 +48,7 @@ jobs:
with:
fetch-depth: 0 # full history so build.sh's rev-list count is accurate
fetch-tags: true # ensure local refs/tags/* so `git describe` resolves the tag
persist-credentials: false

- name: Select Xcode
# Xcode 26 ships the macOS 26 SDK, which is what lights up
Expand Down Expand Up @@ -206,6 +228,7 @@ jobs:
- name: Sign DMG for Sparkle and update appcast
if: env.SIGN_IDENTITY != '' && startsWith(github.ref, 'refs/tags/v')
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
set -euo pipefail
Expand Down Expand Up @@ -264,7 +287,7 @@ jobs:
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add docs/appcast.xml
git commit -m "Update appcast for $TAG_NAME (signed DMG entry)"
git push origin main
git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" main

- name: Package zip (fallback)
run: |
Expand Down
9 changes: 9 additions & 0 deletions Sources/mindle/BrowserDisplaySettings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

enum BrowserDisplaySettings {
static let highlightActiveFileKey = "mindle.fileBrowser.highlightActiveFile"

static func highlightActiveFile(defaults: UserDefaults = .standard) -> Bool {
defaults.object(forKey: highlightActiveFileKey) as? Bool ?? true
}
}
144 changes: 19 additions & 125 deletions Sources/mindle/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ struct ContentView: View {
.lineLimit(1)
.truncationMode(.middle)
.padding(.horizontal, 14)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
if let window = NSApp.keyWindow {
TitleBarDoubleClick.perform(on: window)
}
}
}

ToolbarItemGroup(placement: .primaryAction) {
Expand Down Expand Up @@ -1288,133 +1294,21 @@ struct AnnotationMessageRow: View {

struct FileBrowserSidebar: View {
@EnvironmentObject var store: DocumentStore
@AppStorage(BrowserDisplaySettings.highlightActiveFileKey)
private var highlightActiveFile = true

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)
}
FileBrowserSidebarContent(
rootURL: store.fileTree?.url ?? store.fileURL?.deletingLastPathComponent(),
tree: store.fileTree,
selectedURL: store.fileURL,
isLoading: store.fileBrowserIsLoading,
errorMessage: store.fileBrowserErrorMessage,
highlightActiveFile: highlightActiveFile,
theme: store.theme,
onRefresh: store.refreshFileTree,
onOpen: store.open
)
}
}

Expand Down
52 changes: 32 additions & 20 deletions Sources/mindle/DocumentStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@ extension URL {
}
}

enum ReaderTheme: String, CaseIterable, Codable {
case light, sepia, dark
}

/// What kind of document is in the active tab — picks the renderer pipeline.
/// Markdown flows through the WKWebView + markdown-it pipeline; PDF flows
/// through the native PDFKit pipeline. Derived from the file URL's extension
Expand Down Expand Up @@ -169,14 +165,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 @@ -301,6 +289,9 @@ final class DocumentStore: ObservableObject {
@Published var showAnnotations: Bool = false
@Published var showFileBrowser: Bool = false
@Published var fileTree: FileNode? = nil
@Published var fileBrowserIsLoading = false
@Published var fileBrowserErrorMessage: String?
private var fileBrowserRefreshGeneration = 0

// Tabs (per-window). Empty when no document is open; otherwise the active
// tab's state mirrors `fileURL` / `rawText` / `annotations` above.
Expand Down Expand Up @@ -1175,8 +1166,31 @@ final class DocumentStore: ObservableObject {
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())
guard let url = fileURL else {
fileBrowserRefreshGeneration += 1
fileTree = nil
fileBrowserIsLoading = false
fileBrowserErrorMessage = nil
return
}
let rootURL = url.deletingLastPathComponent()
fileBrowserRefreshGeneration += 1
let generation = fileBrowserRefreshGeneration
fileBrowserIsLoading = true
fileBrowserErrorMessage = nil

Task { @MainActor [weak self] in
await Task.yield()
guard let self, self.fileBrowserRefreshGeneration == generation else { return }
do {
self.fileTree = try Self.buildTree(at: rootURL)
self.fileBrowserErrorMessage = nil
} catch {
self.fileTree = nil
self.fileBrowserErrorMessage = error.localizedDescription
}
self.fileBrowserIsLoading = false
}
}

private static func isDescendant(url: URL, of ancestor: URL) -> Bool {
Expand All @@ -1186,21 +1200,19 @@ final class DocumentStore: ObservableObject {
return uPath.hasPrefix(prefix)
}

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

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 {
if let sub = try? buildTree(at: entry), !(sub.children ?? []).isEmpty {
children.append(sub)
}
} else if browsableExtensions.contains(entry.pathExtension.lowercased()) {
Expand Down
42 changes: 42 additions & 0 deletions Sources/mindle/FileBrowserPresentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Foundation

struct FileNode: Identifiable, Equatable {
var id: URL { url }
let url: URL
let name: String
let isDirectory: Bool
let children: [FileNode]?
}

enum FileBrowserPresentationState: Equatable {
case loading
case error(String)
case populated
case empty
}

enum FileBrowserPresentation {
static func headerTitle(rootURL: URL?) -> String {
guard let title = rootURL?.lastPathComponent, !title.isEmpty else {
return "Files"
}
return title
}

static func state(
tree: FileNode?,
isLoading: Bool,
errorMessage: String?
) -> FileBrowserPresentationState {
if isLoading && tree == nil {
return .loading
}
if let errorMessage {
return .error(errorMessage)
}
if let children = tree?.children, !children.isEmpty {
return .populated
}
return .empty
}
}
Loading