DEV Community

Cover image for Building Just Finder: A Developer-First File Manager in SwiftUI
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on

Building Just Finder: A Developer-First File Manager in SwiftUI

Every developer has felt the friction. You're deep in a coding session, switching between Xcode, VS Code, Finder, and a terminal — just to browse files, preview code, check git status, and open/edit documents. Finder is great for everyday use, but it wasn't built for us — developers who live in text files, repos, and terminals.

So I built Just Finder — a fast, developer-friendly file manager for macOS built entirely in SwiftUI (with some AppKit bridges where SwiftUI hasn't caught up). This blog post dives into the architecture, key technical decisions, and implementation details that made it possible.


The Architecture: Three Panes, One Mission

Just Finder follows a classic three-pane layout familiar to developers: Sidebar | Browser | Preview. At the bottom sits a status bar (file info, git branch, attribution) and an optional terminal drawer.

┌─────────────────────────────────────────────────────────────────┐
│                        MainSplitView                            │
│  ┌──────────┐  ┌──────────────────────┐  ┌──────────────────┐   │
│  │          │  │                      │  │                  │   │
│  │ Sidebar  │  │      Browser         │  │    Preview       │   │
│  │          │  │  ┌────────────────┐  │  │  ┌────────────┐  │   │
│  │ Locations│  │  │ 5 View Modes   │  │  │  │ Code (HL)  │  │   │
│  │ Projects │  │  │ Sort + Filter  │  │  │  │ Markdown   │  │   │
│  │ Memory   │  │  │ Path Bar       │  │  │  │ Mermaid    │  │   │
│  │          │  │  │ Git Status     │  │  │  │ Image/Video│  │   │
│  │          │  │  └────────────────┘  │  │  │ Diff       │  │   │
│  │          │  │                      │  │  └────────────┘  │   │
│  └──────────┘  └──────────────────────┘  └──────────────────┘   │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │                    Status Bar                           │    │
│  └─────────────────────────────────────────────────────────┘    │
│  ┌─────────────────────────────────────────────────────────┐    │
│  │                   Terminal Drawer                       │    │
│  └─────────────────────────────────────────────────────────┘    │
└─────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Each pane is driven by its own @MainActor ViewModel, communicating through a parent MainViewModel that wires them together via Combine publishers.

The MVVM + Combine Wiring

Here's how the browser ↔ preview pipeline works:

// In MainViewModel.swift
browserVM.$selectedItem
    .sink { [weak self] item in
        guard let self, let item else {
            self?.previewVM.clearPreview()
            return
        }
        if !item.isDirectory {
            self?.previewVM.setGitRepoRoot(self?.browserVM.currentRepoRoot)
            self?.previewVM.preview(item)
        }
    }
    .store(in: &cancellables)
Enter fullscreen mode Exit fullscreen mode

This reactive pipeline ensures that selecting a file in the browser immediately triggers a preview load — no manual coordination needed. The PreviewViewModel.preview() method cancels any in-flight preview task (using Swift's structured concurrency) and starts a new one.


The Preview System: Where the Magic Happens

The preview panel was the most challenging and rewarding part to build. It supports: code (syntax-highlighted), markdown (rich text + Mermaid diagrams), images, video, git diffs, and sensitive file blocking — all from a single PreviewContent enum.

The PreviewContent Enum

public enum PreviewContent: Sendable {
    case none
    case loading
    case blocked(SecureFileGuard.SensitiveFile)
    case code(String, String)              // (content, language)
    case markdown(NSAttributedString)       // plain markdown → rich text
    case markdownWithDiagrams(String)       // markdown + mermaid → WKWebView
    case image(NSImage)
    case video(URL)
    case gitDiff(GitDiff)
    case unsupported(String)
    case error(Error)
}
Enter fullscreen mode Exit fullscreen mode

Code Preview with Syntax Highlighting

For code files (JSON, TypeScript, JavaScript), we use the highlights Swift package — a Swift wrapper around highlight.js. The flow is:

  1. Read the file content
  2. Set .code(content, language) on the @Published content
  3. Dispatch a background task to highlight via SyntaxHighlightService.shared.highlight()
  4. Store the result in @Published var highlightedCode: NSAttributedString
private func highlightCode(_ code: String, language: String) async {
    let fontSize = FontSizeManager.shared.scaled(12)
    let highlighted = await Task.detached(priority: .medium) {
        SyntaxHighlightService.shared.highlight(code, language: language, fontSize: fontSize)
    }.value
    guard !Task.isCancelled else { return }
    self.highlightedCode = highlighted
}
Enter fullscreen mode Exit fullscreen mode

The key insight: highlighting runs on a background thread via Task.detached because highlight.js executes JavaScript via JavaScriptCore, which can block the main thread for large files.

Markdown: Two Rendering Paths

Markdown rendering takes two paths depending on content:

Path A: Plain Markdown (NSTextView) — For .md files without diagrams, we use NSAttributedString(markdown:options:) — a macOS 12+ API that parses markdown into a rich attributed string. This is fast, native, and requires no dependencies.

if let doc = try? NSAttributedString(markdown: processedMarkdown, options: .init()) {
    self.content = .markdown(doc)
}
Enter fullscreen mode Exit fullscreen mode

Path B: Diagrams (WKWebView) — When .containsDiagrams() detects mermaid fenced code blocks, we switch to a WKWebView powered by three CDN-loaded JavaScript libraries:

<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
Enter fullscreen mode Exit fullscreen mode

The markdown is converted to HTML client-side with marked.js, code blocks are highlighted with highlight.js, and mermaid code blocks are rendered as SVG diagrams with Mermaid.js.

Critical JavaScript security: The markdown content is embedded into a JavaScript template literal inside a <script> tag. We must escape \, `, ${, and </script> to prevent XSS and HTML parsing errors:

let escaped = markdown
    .replacingOccurrences(of: "\\", with: "\\\\")
    .replacingOccurrences(of: "`", with: "\\`")
    .replacingOccurrences(of: "${", with: "\\${")
    .replacingOccurrences(of: "</script>", with: "<\\/script>")
Enter fullscreen mode Exit fullscreen mode

The </script> escaping is the most subtle — the HTML parser looks for </script> to close the tag, even inside JavaScript string literals. Replacing it with <\\/script> prevents premature tag closure while preserving the correct output in the rendered JavaScript.

Syntax-Highlighted Code Blocks Within Markdown

For the NSTextView path, we extract fenced code blocks before markdown parsing, replace them with placeholder tokens ([[CODEBLOCK_N]]), parse the markdown, then asynchronously highlight each block and substitute it back:

let (processedMarkdown, codeBlocks) = Self.extractCodeBlocks(from: mdContent)
if let doc = try? NSAttributedString(markdown: processedMarkdown, options: .init()) {
    self.content = .markdown(doc)
    if !codeBlocks.isEmpty {
        await highlightMarkdownCodeBlocks(doc, codeBlocks: codeBlocks)
    }
}
Enter fullscreen mode Exit fullscreen mode

The extraction regex:

let pattern = #"```

([a-zA-Z0-9_+#.\-]*)\n([\s\S]*?)

```"#
Enter fullscreen mode Exit fullscreen mode

Matches are processed in reverse order (last to first) so that replacing with placeholders doesn't invalidate earlier range positions.


The Theme Engine: Six Profiles, Three Modes

The theme system was designed to be both powerful and easy. A central @MainActor ThemeEngine publishes a single AppTheme struct that every view consumes via @EnvironmentObject.

@MainActor
public final class ThemeEngine: ObservableObject {
    @Published public var mode: ThemeMode      // light, dark, auto
    @Published public var profile: ThemeProfile // default, oneDark, dracula, nord, catppuccin, solarized
    @Published public private(set) var current: AppTheme
}
Enter fullscreen mode Exit fullscreen mode

Each AppTheme is a value-type struct with ~40 color properties — text, backgrounds, borders, accents, syntax colors, etc. Switching themes is O(1): just publish a new struct and SwiftUI diffing handles the rest.

extension AppTheme {
    static let oneDark = AppTheme(
        name: "One Dark Pro",
        text: Color(hex: "#ABB2BF"),
        accent: Color(hex: "#61AFEF"),
        accentSecondary: Color(hex: "#C678DD"),
        background: Color(hex: "#282C34"),
        // ... 40+ color properties
    )
}
Enter fullscreen mode Exit fullscreen mode

Git Integration: Status at a Glance

Git integration was designed to be low-friction. The GitService runs git commands as shell subprocesses and parses the output:

  • File status badges — The FileItem model includes a gitStatus property (.gitModified, .gitAdded, .gitDeleted, .gitRenamed) that the file row renders as colored badges
  • Branch + ahead/behind — The status bar shows the current branch with up/down arrow indicators
  • Inline diff — The preview panel has a "Diff" toggle that shows line-by-line additions/deletions with color coding
private func gitDiffPreview(_ diff: GitDiff) -> some View {
    // Each hunk has a header and lines
    // Lines are context (grey), addition (green bg), or deletion (red bg)
    // Line numbers shown in the left gutter
}
Enter fullscreen mode Exit fullscreen mode

Security: Protecting Sensitive Files

The SecureFileGuard service uses a set of regex patterns to detect sensitive files — SSH keys, .env files, credential files, configuration with secrets, etc. When a sensitive file is selected for preview, the user sees a lock screen with three options:

  1. Reveal Once — Show the file content just this time
  2. Always Ask — Prompt every time this file is accessed
  3. Never Preview — Block preview permanently
if let sensitiveFile = secureFileGuard.isSensitive(item.url) {
    // Show blocked view with choice buttons
    content = .blocked(sensitiveFile)
}
Enter fullscreen mode Exit fullscreen mode

Sparkle: Conditional Compilation for Auto-Updates

Sparkle auto-update is a release-only feature. In debug builds, it's completely compiled out to avoid requiring Sparkle keys in Info.plist during development:

#if !DEBUG
import Sparkle
// Full Sparkle implementation...
#else
// No-op stubs
final class UpdaterController: ObservableObject {
    static let shared = UpdaterController()
}
struct CheckForUpdatesView: View {
    var body: some View {
        Button("Check for Updates…") {}
            .disabled(true)
    }
}
#endif
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

1. NSTextView is still king for rich text

SwiftUI's Text view is great for static text, but for rich rendered content (markdown, syntax highlighting), bridging to NSTextView via NSViewRepresentable is the right call.

2. WKWebView needs careful escaping

Embedding user content into a WKWebView HTML page requires escaping for three contexts simultaneously — Swift string, JavaScript template literal, and HTML parser.

3. Task.detached for CPU-intensive work

JavaScriptCore (used by highlight.js) can block the main thread for hundreds of milliseconds on large files. Wrapping it in Task.detached(priority: .medium) keeps the UI responsive.

4. Reverse-order replacement is essential

When replacing multiple substrings in a string (like code block placeholders), process from last to first to avoid range invalidation.

5. Theme as a value type makes switching instant

Using a single @Published struct for the entire theme means SwiftUI only diffes changed properties — no wasted re-renders.


What's Next?

  • Tabs — The database schema already has a tabs table ready
  • Find in Files — Project-wide full-text search with ripgrep
  • Git UI — Staging, unstaging, commit messages
  • Plugin system — Custom previewers for any file type

This project is open source and available on GitHub. Built with SwiftUI, love, and lots of coffee.

Code & more: https://www.dailybuild.xyz/project/185-just-finder

Top comments (0)