2026-02-06 18:59:53 +00:00
|
|
|
import SwiftUI
|
|
|
|
|
import Foundation
|
|
|
|
|
|
2026-02-19 14:29:53 +00:00
|
|
|
#if os(macOS)
|
2026-03-09 16:47:50 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
/// MARK: - Types
|
|
|
|
|
|
2026-02-19 14:29:53 +00:00
|
|
|
private enum MacTranslucencyMode: String {
|
|
|
|
|
case subtle
|
|
|
|
|
case balanced
|
|
|
|
|
case vibrant
|
|
|
|
|
|
|
|
|
|
var material: Material {
|
|
|
|
|
switch self {
|
|
|
|
|
case .subtle, .balanced:
|
|
|
|
|
return .thickMaterial
|
|
|
|
|
case .vibrant:
|
|
|
|
|
return .regularMaterial
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var opacity: Double {
|
|
|
|
|
switch self {
|
|
|
|
|
case .subtle: return 0.98
|
|
|
|
|
case .balanced: return 0.93
|
|
|
|
|
case .vibrant: return 0.90
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
struct SidebarView: View {
|
2026-02-20 10:34:22 +00:00
|
|
|
private struct TOCItem: Identifiable, Hashable {
|
|
|
|
|
let id: String
|
|
|
|
|
let title: String
|
|
|
|
|
let line: Int?
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
let content: String
|
|
|
|
|
let language: String
|
2026-02-19 14:29:53 +00:00
|
|
|
let translucentBackgroundEnabled: Bool
|
2026-02-19 08:09:35 +00:00
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
2026-02-19 14:29:53 +00:00
|
|
|
#if os(macOS)
|
|
|
|
|
@AppStorage("SettingsMacTranslucencyMode") private var macTranslucencyModeRaw: String = "balanced"
|
|
|
|
|
#endif
|
2026-02-20 10:34:22 +00:00
|
|
|
@State private var tocItems: [TOCItem] = [
|
|
|
|
|
TOCItem(id: "empty", title: "No content available", line: nil)
|
|
|
|
|
]
|
2026-02-19 08:09:35 +00:00
|
|
|
@State private var tocRefreshTask: Task<Void, Never>?
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
var body: some View {
|
|
|
|
|
List {
|
2026-02-20 10:34:22 +00:00
|
|
|
ForEach(tocItems) { item in
|
2026-02-06 18:59:53 +00:00
|
|
|
Button {
|
|
|
|
|
jump(to: item)
|
|
|
|
|
} label: {
|
2026-02-20 10:34:22 +00:00
|
|
|
Text(item.title)
|
2026-02-06 18:59:53 +00:00
|
|
|
.font(.system(size: 13))
|
|
|
|
|
.foregroundColor(.primary)
|
2026-02-19 14:29:53 +00:00
|
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
|
.padding(.vertical, 8)
|
|
|
|
|
.padding(.horizontal, 12)
|
|
|
|
|
.background(
|
|
|
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
|
|
.fill(sidebarRowFill)
|
|
|
|
|
)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
2026-02-20 10:34:22 +00:00
|
|
|
.disabled(item.line == nil)
|
2026-02-19 14:29:53 +00:00
|
|
|
.listRowBackground(Color.clear)
|
|
|
|
|
.listRowSeparator(.hidden)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-19 14:29:53 +00:00
|
|
|
.listStyle(platformListStyle)
|
2026-02-06 18:59:53 +00:00
|
|
|
.scrollContentBackground(.hidden)
|
|
|
|
|
.background(Color.clear)
|
2026-02-19 14:29:53 +00:00
|
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
|
.padding(sidebarOuterPaddingInsets)
|
2026-02-19 08:09:35 +00:00
|
|
|
.background(
|
2026-02-19 14:29:53 +00:00
|
|
|
RoundedRectangle(cornerRadius: sidebarCornerRadius, style: .continuous)
|
|
|
|
|
.fill(sidebarSurfaceFill)
|
2026-02-19 08:09:35 +00:00
|
|
|
.overlay(
|
2026-02-19 14:29:53 +00:00
|
|
|
RoundedRectangle(cornerRadius: sidebarCornerRadius, style: .continuous)
|
|
|
|
|
.stroke(sidebarSurfaceStroke, lineWidth: 1)
|
2026-02-19 08:09:35 +00:00
|
|
|
)
|
|
|
|
|
)
|
2026-02-19 14:29:53 +00:00
|
|
|
.clipShape(RoundedRectangle(cornerRadius: sidebarCornerRadius, style: .continuous))
|
2026-02-19 08:09:35 +00:00
|
|
|
.onAppear {
|
|
|
|
|
scheduleTOCRefresh()
|
|
|
|
|
}
|
|
|
|
|
.onChange(of: content) { _, _ in
|
|
|
|
|
scheduleTOCRefresh()
|
|
|
|
|
}
|
|
|
|
|
.onChange(of: language) { _, _ in
|
|
|
|
|
scheduleTOCRefresh()
|
|
|
|
|
}
|
|
|
|
|
.onDisappear {
|
|
|
|
|
tocRefreshTask?.cancel()
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-19 14:29:53 +00:00
|
|
|
private var sidebarSurfaceFill: AnyShapeStyle {
|
|
|
|
|
if translucentBackgroundEnabled {
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
let mode = MacTranslucencyMode(rawValue: macTranslucencyModeRaw) ?? .balanced
|
|
|
|
|
return AnyShapeStyle(mode.material.opacity(mode.opacity))
|
|
|
|
|
#else
|
|
|
|
|
return AnyShapeStyle(.ultraThinMaterial)
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
return AnyShapeStyle(Color(nsColor: .textBackgroundColor))
|
|
|
|
|
#else
|
2026-02-20 01:16:58 +00:00
|
|
|
if colorScheme == .dark {
|
|
|
|
|
return AnyShapeStyle(
|
|
|
|
|
LinearGradient(
|
|
|
|
|
colors: [
|
|
|
|
|
Color(red: 0.11, green: 0.13, blue: 0.17),
|
|
|
|
|
Color(red: 0.15, green: 0.18, blue: 0.23)
|
|
|
|
|
],
|
|
|
|
|
startPoint: .topLeading,
|
|
|
|
|
endPoint: .bottomTrailing
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-02-19 14:29:53 +00:00
|
|
|
return AnyShapeStyle(
|
|
|
|
|
LinearGradient(
|
|
|
|
|
colors: [
|
|
|
|
|
Color(red: 0.92, green: 0.96, blue: 1.0),
|
|
|
|
|
Color(red: 0.88, green: 0.93, blue: 1.0)
|
|
|
|
|
],
|
|
|
|
|
startPoint: .topLeading,
|
|
|
|
|
endPoint: .bottomTrailing
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var sidebarSurfaceStroke: Color {
|
|
|
|
|
colorScheme == .dark
|
|
|
|
|
? Color.white.opacity(0.12)
|
|
|
|
|
: Color.black.opacity(0.08)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var platformListStyle: some ListStyle {
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
PlainListStyle()
|
|
|
|
|
#else
|
|
|
|
|
SidebarListStyle()
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var sidebarRowFill: Color {
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
Color.secondary.opacity(0.10)
|
|
|
|
|
#else
|
2026-02-20 01:16:58 +00:00
|
|
|
colorScheme == .dark
|
|
|
|
|
? Color.white.opacity(0.06)
|
|
|
|
|
: Color(red: 0.80, green: 0.88, blue: 1.0).opacity(0.55)
|
2026-02-19 14:29:53 +00:00
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var sidebarOuterPaddingInsets: EdgeInsets {
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
EdgeInsets(top: 0, leading: 10, bottom: 10, trailing: 10)
|
|
|
|
|
#else
|
|
|
|
|
EdgeInsets()
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var sidebarCornerRadius: CGFloat {
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
0
|
|
|
|
|
#else
|
|
|
|
|
14
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 10:34:22 +00:00
|
|
|
private func jump(to item: TOCItem) {
|
|
|
|
|
guard let lineOneBased = item.line, lineOneBased > 0 else { return }
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
|
NotificationCenter.default.post(name: .moveCursorToLine, object: lineOneBased)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 08:09:35 +00:00
|
|
|
private func scheduleTOCRefresh() {
|
|
|
|
|
tocRefreshTask?.cancel()
|
|
|
|
|
let snapshotContent = content
|
|
|
|
|
let snapshotLanguage = language
|
|
|
|
|
tocRefreshTask = Task(priority: .utility) {
|
|
|
|
|
try? await Task.sleep(nanoseconds: 120_000_000)
|
|
|
|
|
guard !Task.isCancelled else { return }
|
|
|
|
|
let generated = SidebarView.generateTableOfContents(content: snapshotContent, language: snapshotLanguage)
|
|
|
|
|
await MainActor.run {
|
|
|
|
|
tocItems = generated
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
// Naive line-scanning TOC: looks for language-specific declarations or headers.
|
2026-02-20 10:34:22 +00:00
|
|
|
private static func generateTableOfContents(content: String, language: String) -> [TOCItem] {
|
|
|
|
|
guard !content.isEmpty else {
|
|
|
|
|
return [TOCItem(id: "empty", title: "No content available", line: nil)]
|
|
|
|
|
}
|
2026-02-08 11:57:41 +00:00
|
|
|
if (content as NSString).length >= 400_000 {
|
2026-02-20 10:34:22 +00:00
|
|
|
return [TOCItem(id: "large", title: "Large file detected: TOC disabled for performance", line: nil)]
|
2026-02-08 11:57:41 +00:00
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
let lines = content.components(separatedBy: .newlines)
|
2026-02-20 10:34:22 +00:00
|
|
|
var toc: [TOCItem] = []
|
2026-02-06 18:59:53 +00:00
|
|
|
|
|
|
|
|
switch language {
|
|
|
|
|
case "swift":
|
|
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
if trimmed.hasPrefix("func ") || trimmed.hasPrefix("struct ") ||
|
|
|
|
|
trimmed.hasPrefix("class ") || trimmed.hasPrefix("enum ") {
|
2026-02-20 10:34:22 +00:00
|
|
|
return TOCItem(id: "swift-\(index)", title: "\(trimmed) (Line \(index + 1))", line: index + 1)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
case "python":
|
|
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
if trimmed.hasPrefix("def ") || trimmed.hasPrefix("class ") {
|
2026-02-20 10:34:22 +00:00
|
|
|
return TOCItem(id: "python-\(index)", title: "\(trimmed) (Line \(index + 1))", line: index + 1)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
case "javascript":
|
|
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
if trimmed.hasPrefix("function ") || trimmed.hasPrefix("class ") {
|
2026-02-20 10:34:22 +00:00
|
|
|
return TOCItem(id: "js-\(index)", title: "\(trimmed) (Line \(index + 1))", line: index + 1)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
case "java":
|
|
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
if t.hasPrefix("class ") || (t.contains(" void ") || (t.contains(" public ") && t.contains("(") && t.contains(")"))) {
|
2026-02-20 10:34:22 +00:00
|
|
|
return TOCItem(id: "java-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
case "kotlin":
|
|
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
if t.hasPrefix("class ") || t.hasPrefix("object ") || t.hasPrefix("fun ") {
|
2026-02-20 10:34:22 +00:00
|
|
|
return TOCItem(id: "kotlin-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
case "go":
|
|
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
if t.hasPrefix("func ") || t.hasPrefix("type ") {
|
2026-02-20 10:34:22 +00:00
|
|
|
return TOCItem(id: "go-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
case "ruby":
|
|
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
if t.hasPrefix("def ") || t.hasPrefix("class ") || t.hasPrefix("module ") {
|
2026-02-20 10:34:22 +00:00
|
|
|
return TOCItem(id: "ruby-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
case "rust":
|
|
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
if t.hasPrefix("fn ") || t.hasPrefix("struct ") || t.hasPrefix("enum ") || t.hasPrefix("impl ") {
|
2026-02-20 10:34:22 +00:00
|
|
|
return TOCItem(id: "rust-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
case "typescript":
|
|
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
if t.hasPrefix("function ") || t.hasPrefix("class ") || t.hasPrefix("interface ") || t.hasPrefix("type ") {
|
2026-02-20 10:34:22 +00:00
|
|
|
return TOCItem(id: "ts-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-02-07 23:20:47 +00:00
|
|
|
case "php":
|
|
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
if t.hasPrefix("function ") || t.hasPrefix("class ") || t.hasPrefix("interface ") || t.hasPrefix("trait ") {
|
2026-02-20 10:34:22 +00:00
|
|
|
return TOCItem(id: "php-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1)
|
2026-02-07 23:20:47 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
case "objective-c":
|
|
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
if t.hasPrefix("@interface") || t.hasPrefix("@implementation") || t.contains(")\n{") {
|
2026-02-20 10:34:22 +00:00
|
|
|
return TOCItem(id: "objc-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
case "c", "cpp":
|
|
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
if trimmed.contains("(") && !trimmed.contains(";") && (trimmed.hasPrefix("void ") || trimmed.hasPrefix("int ") || trimmed.hasPrefix("float ") || trimmed.hasPrefix("double ") || trimmed.hasPrefix("char ") || trimmed.contains("{")) {
|
2026-02-20 10:34:22 +00:00
|
|
|
return TOCItem(id: "c-\(index)", title: "\(trimmed) (Line \(index + 1))", line: index + 1)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
case "bash", "zsh":
|
|
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
// Simple function detection: name() { or function name { or name()\n{
|
|
|
|
|
if trimmed.range(of: "^([A-Za-z_][A-Za-z0-9_]*)\\s*\\(\\)\\s*\\{", options: .regularExpression) != nil ||
|
|
|
|
|
trimmed.range(of: "^function\\s+[A-Za-z_][A-Za-z0-9_]*\\s*\\{", options: .regularExpression) != nil {
|
2026-02-20 10:34:22 +00:00
|
|
|
return TOCItem(id: "sh-\(index)", title: "\(trimmed) (Line \(index + 1))", line: index + 1)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
case "powershell":
|
|
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
if trimmed.range(of: #"^function\s+[A-Za-z_][A-Za-z0-9_\-]*\s*\{"#, options: .regularExpression) != nil ||
|
|
|
|
|
trimmed.hasPrefix("param(") {
|
2026-02-20 10:34:22 +00:00
|
|
|
return TOCItem(id: "ps-\(index)", title: "\(trimmed) (Line \(index + 1))", line: index + 1)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-02-07 23:20:47 +00:00
|
|
|
case "html", "css", "json", "markdown", "csv":
|
2026-02-06 18:59:53 +00:00
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
if !trimmed.isEmpty && (trimmed.hasPrefix("#") || trimmed.hasPrefix("<h")) {
|
2026-02-20 10:34:22 +00:00
|
|
|
return TOCItem(id: "markup-\(index)", title: "\(trimmed) (Line \(index + 1))", line: index + 1)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
case "csharp":
|
|
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
|
if t.hasPrefix("class ") || t.hasPrefix("interface ") || t.hasPrefix("enum ") || t.contains(" static void Main(") || (t.contains(" void ") && t.contains("(") && t.contains(")") && t.contains("{")) {
|
2026-02-20 10:34:22 +00:00
|
|
|
return TOCItem(id: "cs-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
default:
|
|
|
|
|
// For unknown or standard/plain, show first non-empty lines as headings
|
|
|
|
|
toc = lines.enumerated().compactMap { index, line in
|
|
|
|
|
let t = line.trimmingCharacters(in: .whitespaces)
|
2026-02-20 10:34:22 +00:00
|
|
|
if !t.isEmpty && t.count < 120 {
|
|
|
|
|
return TOCItem(id: "default-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1)
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 10:34:22 +00:00
|
|
|
return toc.isEmpty
|
|
|
|
|
? [TOCItem(id: "none", title: "No headers found", line: nil)]
|
|
|
|
|
: toc
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
struct ProjectStructureSidebarView: View {
|
2026-03-09 16:47:50 +00:00
|
|
|
private enum SidebarDensity: String, CaseIterable, Identifiable {
|
|
|
|
|
case compact
|
|
|
|
|
case comfortable
|
|
|
|
|
|
|
|
|
|
var id: String { rawValue }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private struct FileIconStyle {
|
|
|
|
|
let symbol: String
|
|
|
|
|
let color: Color
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
let rootFolderURL: URL?
|
2026-02-19 08:09:35 +00:00
|
|
|
let nodes: [ProjectTreeNode]
|
2026-02-06 18:59:53 +00:00
|
|
|
let selectedFileURL: URL?
|
2026-03-08 14:31:01 +00:00
|
|
|
let showSupportedFilesOnly: Bool
|
2026-02-06 18:59:53 +00:00
|
|
|
let translucentBackgroundEnabled: Bool
|
|
|
|
|
let onOpenFile: () -> Void
|
|
|
|
|
let onOpenFolder: () -> Void
|
2026-03-08 14:31:01 +00:00
|
|
|
let onToggleSupportedFilesOnly: (Bool) -> Void
|
2026-02-06 18:59:53 +00:00
|
|
|
let onOpenProjectFile: (URL) -> Void
|
|
|
|
|
let onRefreshTree: () -> Void
|
|
|
|
|
@State private var expandedDirectories: Set<String> = []
|
2026-02-19 08:09:35 +00:00
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
2026-02-19 14:29:53 +00:00
|
|
|
#if os(macOS)
|
|
|
|
|
@AppStorage("SettingsMacTranslucencyMode") private var macTranslucencyModeRaw: String = "balanced"
|
|
|
|
|
#endif
|
2026-03-09 16:47:50 +00:00
|
|
|
@AppStorage("SettingsProjectSidebarDensity") private var sidebarDensityRaw: String = SidebarDensity.compact.rawValue
|
|
|
|
|
@AppStorage("SettingsProjectSidebarAutoCollapseDeep") private var autoCollapseDeepFolders: Bool = true
|
2026-02-18 22:56:46 +00:00
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
var body: some View {
|
2026-02-19 14:29:53 +00:00
|
|
|
VStack(alignment: .leading, spacing: 0) {
|
2026-02-06 18:59:53 +00:00
|
|
|
HStack {
|
|
|
|
|
Text("Project Structure")
|
|
|
|
|
.font(.headline)
|
|
|
|
|
Spacer()
|
|
|
|
|
Button(action: onOpenFolder) {
|
|
|
|
|
Image(systemName: "folder.badge.plus")
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.borderless)
|
|
|
|
|
.help("Open Folder…")
|
|
|
|
|
|
|
|
|
|
Button(action: onOpenFile) {
|
|
|
|
|
Image(systemName: "doc.badge.plus")
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.borderless)
|
|
|
|
|
.help("Open File…")
|
|
|
|
|
|
|
|
|
|
Button(action: onRefreshTree) {
|
|
|
|
|
Image(systemName: "arrow.clockwise")
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.borderless)
|
|
|
|
|
.help("Refresh Folder Tree")
|
2026-03-08 14:31:01 +00:00
|
|
|
|
|
|
|
|
Menu {
|
|
|
|
|
Button {
|
|
|
|
|
onToggleSupportedFilesOnly(!showSupportedFilesOnly)
|
|
|
|
|
} label: {
|
|
|
|
|
Label(
|
|
|
|
|
"Show Supported Files Only",
|
|
|
|
|
systemImage: showSupportedFilesOnly ? "checkmark.circle.fill" : "circle"
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
Divider()
|
2026-03-09 16:47:50 +00:00
|
|
|
Picker("Density", selection: $sidebarDensityRaw) {
|
|
|
|
|
Text("Compact").tag(SidebarDensity.compact.rawValue)
|
|
|
|
|
Text("Comfortable").tag(SidebarDensity.comfortable.rawValue)
|
|
|
|
|
}
|
|
|
|
|
Toggle("Auto-collapse Deep Folders", isOn: $autoCollapseDeepFolders)
|
|
|
|
|
Divider()
|
2026-03-08 14:31:01 +00:00
|
|
|
Button("Expand All") {
|
|
|
|
|
expandAllDirectories()
|
|
|
|
|
}
|
|
|
|
|
Button("Collapse All") {
|
|
|
|
|
collapseAllDirectories()
|
|
|
|
|
}
|
|
|
|
|
} label: {
|
|
|
|
|
Image(systemName: "arrow.up.arrow.down.circle")
|
|
|
|
|
}
|
|
|
|
|
.buttonStyle(.borderless)
|
|
|
|
|
.help("Expand or Collapse All")
|
|
|
|
|
.accessibilityLabel("Expand or collapse all folders")
|
|
|
|
|
.accessibilityHint("Expands or collapses all folders in the project tree")
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
2026-03-09 16:47:50 +00:00
|
|
|
.padding(.horizontal, headerHorizontalPadding)
|
|
|
|
|
.padding(.top, headerTopPadding)
|
|
|
|
|
.padding(.bottom, headerBottomPadding)
|
2026-02-19 14:29:53 +00:00
|
|
|
#if os(macOS)
|
|
|
|
|
.background(sidebarHeaderFill)
|
|
|
|
|
#endif
|
2026-02-06 18:59:53 +00:00
|
|
|
|
|
|
|
|
if let rootFolderURL {
|
|
|
|
|
Text(rootFolderURL.path)
|
2026-03-09 16:47:50 +00:00
|
|
|
.font(.system(size: isCompactDensity ? 11 : 12))
|
2026-02-06 18:59:53 +00:00
|
|
|
.foregroundStyle(.secondary)
|
2026-03-09 16:47:50 +00:00
|
|
|
.lineLimit(isCompactDensity ? 1 : 2)
|
2026-02-06 18:59:53 +00:00
|
|
|
.textSelection(.enabled)
|
2026-03-09 16:47:50 +00:00
|
|
|
.padding(.horizontal, headerHorizontalPadding)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
List {
|
|
|
|
|
if rootFolderURL == nil {
|
|
|
|
|
Text("No folder selected")
|
|
|
|
|
.foregroundColor(.secondary)
|
2026-02-19 14:29:53 +00:00
|
|
|
.listRowBackground(Color.clear)
|
|
|
|
|
.listRowSeparator(.hidden)
|
2026-02-06 18:59:53 +00:00
|
|
|
} else if nodes.isEmpty {
|
|
|
|
|
Text("Folder is empty")
|
|
|
|
|
.foregroundColor(.secondary)
|
2026-02-19 14:29:53 +00:00
|
|
|
.listRowBackground(Color.clear)
|
|
|
|
|
.listRowSeparator(.hidden)
|
2026-02-06 18:59:53 +00:00
|
|
|
} else {
|
|
|
|
|
ForEach(nodes) { node in
|
|
|
|
|
projectNodeView(node, level: 0)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-19 14:29:53 +00:00
|
|
|
.listStyle(platformListStyle)
|
2026-02-06 18:59:53 +00:00
|
|
|
.scrollContentBackground(.hidden)
|
2026-02-19 14:29:53 +00:00
|
|
|
.background(Color.clear)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
2026-02-19 14:29:53 +00:00
|
|
|
.padding(sidebarOuterPadding)
|
2026-02-19 08:09:35 +00:00
|
|
|
.background(
|
2026-02-19 14:29:53 +00:00
|
|
|
RoundedRectangle(cornerRadius: sidebarCornerRadius, style: .continuous)
|
|
|
|
|
.fill(sidebarSurfaceFill)
|
2026-02-19 08:09:35 +00:00
|
|
|
.overlay(
|
2026-02-19 14:29:53 +00:00
|
|
|
RoundedRectangle(cornerRadius: sidebarCornerRadius, style: .continuous)
|
|
|
|
|
.stroke(sidebarSurfaceStroke, lineWidth: 1)
|
2026-02-19 08:09:35 +00:00
|
|
|
)
|
|
|
|
|
)
|
2026-02-19 14:29:53 +00:00
|
|
|
.clipShape(RoundedRectangle(cornerRadius: sidebarCornerRadius, style: .continuous))
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
.overlay(alignment: .leading) {
|
|
|
|
|
Rectangle()
|
|
|
|
|
.fill(sidebarSurfaceFill)
|
|
|
|
|
.frame(width: 2)
|
|
|
|
|
}
|
|
|
|
|
.overlay(alignment: .leading) {
|
|
|
|
|
Rectangle()
|
|
|
|
|
.fill(sidebarSeparatorColor)
|
|
|
|
|
.frame(width: 1)
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var sidebarSurfaceFill: AnyShapeStyle {
|
|
|
|
|
if translucentBackgroundEnabled {
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
let mode = MacTranslucencyMode(rawValue: macTranslucencyModeRaw) ?? .balanced
|
|
|
|
|
return AnyShapeStyle(mode.material.opacity(mode.opacity))
|
|
|
|
|
#else
|
|
|
|
|
return AnyShapeStyle(.ultraThinMaterial)
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
return AnyShapeStyle(Color(nsColor: .textBackgroundColor))
|
|
|
|
|
#else
|
2026-02-20 01:16:58 +00:00
|
|
|
if colorScheme == .dark {
|
|
|
|
|
return AnyShapeStyle(
|
|
|
|
|
LinearGradient(
|
|
|
|
|
colors: [
|
|
|
|
|
Color(red: 0.11, green: 0.13, blue: 0.17),
|
|
|
|
|
Color(red: 0.15, green: 0.18, blue: 0.23)
|
|
|
|
|
],
|
|
|
|
|
startPoint: .topLeading,
|
|
|
|
|
endPoint: .bottomTrailing
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-02-19 14:29:53 +00:00
|
|
|
return AnyShapeStyle(
|
|
|
|
|
LinearGradient(
|
|
|
|
|
colors: [
|
|
|
|
|
Color(red: 0.92, green: 0.96, blue: 1.0),
|
|
|
|
|
Color(red: 0.88, green: 0.93, blue: 1.0)
|
|
|
|
|
],
|
|
|
|
|
startPoint: .topLeading,
|
|
|
|
|
endPoint: .bottomTrailing
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var sidebarSurfaceStroke: Color {
|
|
|
|
|
colorScheme == .dark
|
|
|
|
|
? Color.white.opacity(0.12)
|
|
|
|
|
: Color.black.opacity(0.08)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var sidebarHeaderFill: AnyShapeStyle {
|
|
|
|
|
translucentBackgroundEnabled ? sidebarSurfaceFill : AnyShapeStyle(Color.clear)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var sidebarSeparatorColor: Color {
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
Color(nsColor: .separatorColor).opacity(0.7)
|
|
|
|
|
#else
|
|
|
|
|
Color.black.opacity(0.1)
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var sidebarCornerRadius: CGFloat {
|
|
|
|
|
#if os(macOS)
|
|
|
|
|
0
|
|
|
|
|
#else
|
|
|
|
|
14
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var sidebarOuterPadding: CGFloat {
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
10
|
|
|
|
|
#else
|
|
|
|
|
0
|
|
|
|
|
#endif
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var platformListStyle: some ListStyle {
|
|
|
|
|
#if os(iOS)
|
|
|
|
|
PlainListStyle()
|
|
|
|
|
#else
|
|
|
|
|
PlainListStyle()
|
|
|
|
|
#endif
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-08 14:31:01 +00:00
|
|
|
private func expandAllDirectories() {
|
2026-03-09 16:47:50 +00:00
|
|
|
expandedDirectories = allDirectoryNodeIDs(in: nodes, level: 0)
|
2026-03-08 14:31:01 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func collapseAllDirectories() {
|
|
|
|
|
expandedDirectories.removeAll()
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 16:47:50 +00:00
|
|
|
private func allDirectoryNodeIDs(in treeNodes: [ProjectTreeNode], level: Int) -> Set<String> {
|
2026-03-08 14:31:01 +00:00
|
|
|
var result: Set<String> = []
|
|
|
|
|
for node in treeNodes where node.isDirectory {
|
2026-03-09 16:47:50 +00:00
|
|
|
let shouldInclude = !autoCollapseDeepFolders || level < 2
|
|
|
|
|
if shouldInclude {
|
|
|
|
|
result.insert(node.id)
|
|
|
|
|
}
|
|
|
|
|
result.formUnion(allDirectoryNodeIDs(in: node.children, level: level + 1))
|
2026-03-08 14:31:01 +00:00
|
|
|
}
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-06 18:59:53 +00:00
|
|
|
private func projectNodeView(_ node: ProjectTreeNode, level: Int) -> AnyView {
|
|
|
|
|
if node.isDirectory {
|
|
|
|
|
return AnyView(
|
|
|
|
|
DisclosureGroup(isExpanded: Binding(
|
|
|
|
|
get: { expandedDirectories.contains(node.id) },
|
|
|
|
|
set: { isExpanded in
|
|
|
|
|
if isExpanded {
|
|
|
|
|
expandedDirectories.insert(node.id)
|
|
|
|
|
} else {
|
|
|
|
|
expandedDirectories.remove(node.id)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
)) {
|
|
|
|
|
ForEach(node.children) { child in
|
|
|
|
|
projectNodeView(child, level: level + 1)
|
|
|
|
|
}
|
|
|
|
|
} label: {
|
2026-03-09 16:47:50 +00:00
|
|
|
HStack(spacing: 8) {
|
|
|
|
|
Image(systemName: "folder")
|
|
|
|
|
.foregroundStyle(.blue)
|
|
|
|
|
.symbolRenderingMode(.hierarchical)
|
|
|
|
|
Text(node.url.lastPathComponent)
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
}
|
|
|
|
|
.padding(.vertical, rowVerticalPadding)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
2026-03-09 16:47:50 +00:00
|
|
|
.padding(.leading, CGFloat(level) * levelIndent)
|
|
|
|
|
.listRowInsets(rowInsets)
|
2026-02-06 18:59:53 +00:00
|
|
|
.listRowBackground(Color.clear)
|
2026-02-19 14:29:53 +00:00
|
|
|
.listRowSeparator(.hidden)
|
2026-02-06 18:59:53 +00:00
|
|
|
)
|
|
|
|
|
} else {
|
2026-03-09 16:47:50 +00:00
|
|
|
let style = fileIconStyle(for: node.url)
|
2026-02-06 18:59:53 +00:00
|
|
|
return AnyView(
|
|
|
|
|
Button {
|
|
|
|
|
onOpenProjectFile(node.url)
|
|
|
|
|
} label: {
|
|
|
|
|
HStack(spacing: 8) {
|
2026-03-09 16:47:50 +00:00
|
|
|
Image(systemName: style.symbol)
|
|
|
|
|
.foregroundStyle(style.color)
|
|
|
|
|
.symbolRenderingMode(.hierarchical)
|
2026-02-06 18:59:53 +00:00
|
|
|
Text(node.url.lastPathComponent)
|
|
|
|
|
.lineLimit(1)
|
|
|
|
|
Spacer()
|
|
|
|
|
if selectedFileURL?.standardizedFileURL == node.url.standardizedFileURL {
|
|
|
|
|
Image(systemName: "checkmark.circle.fill")
|
|
|
|
|
.foregroundColor(.accentColor)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-09 16:47:50 +00:00
|
|
|
.padding(.vertical, rowVerticalPadding)
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
.buttonStyle(.plain)
|
2026-03-09 16:47:50 +00:00
|
|
|
.padding(.leading, CGFloat(level) * levelIndent)
|
|
|
|
|
.listRowInsets(rowInsets)
|
2026-02-06 18:59:53 +00:00
|
|
|
.listRowBackground(Color.clear)
|
2026-02-19 14:29:53 +00:00
|
|
|
.listRowSeparator(.hidden)
|
2026-02-06 18:59:53 +00:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-09 16:47:50 +00:00
|
|
|
|
|
|
|
|
private var sidebarDensity: SidebarDensity {
|
|
|
|
|
SidebarDensity(rawValue: sidebarDensityRaw) ?? .compact
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var isCompactDensity: Bool { sidebarDensity == .compact }
|
|
|
|
|
|
|
|
|
|
private var levelIndent: CGFloat {
|
|
|
|
|
isCompactDensity ? 8 : 12
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var rowVerticalPadding: CGFloat {
|
|
|
|
|
isCompactDensity ? 1 : 4
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var headerHorizontalPadding: CGFloat {
|
|
|
|
|
isCompactDensity ? 8 : 10
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var headerTopPadding: CGFloat {
|
|
|
|
|
isCompactDensity ? 8 : 10
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var headerBottomPadding: CGFloat {
|
|
|
|
|
isCompactDensity ? 6 : 8
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private var rowInsets: EdgeInsets {
|
|
|
|
|
EdgeInsets(top: 0, leading: isCompactDensity ? 4 : 6, bottom: 0, trailing: 4)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private func fileIconStyle(for url: URL) -> FileIconStyle {
|
|
|
|
|
let ext = url.pathExtension.lowercased()
|
|
|
|
|
let name = url.lastPathComponent.lowercased()
|
|
|
|
|
|
|
|
|
|
switch ext {
|
|
|
|
|
case "swift":
|
|
|
|
|
return .init(symbol: "swift", color: .orange)
|
|
|
|
|
case "js", "mjs", "cjs":
|
|
|
|
|
return .init(symbol: "curlybraces.square", color: .yellow)
|
|
|
|
|
case "ts", "tsx":
|
|
|
|
|
return .init(symbol: "chevron.left.forwardslash.chevron.right", color: .blue)
|
|
|
|
|
case "json", "jsonc", "json5":
|
|
|
|
|
return .init(symbol: "curlybraces", color: .green)
|
|
|
|
|
case "md", "markdown":
|
|
|
|
|
return .init(symbol: "text.alignleft", color: .teal)
|
|
|
|
|
case "yml", "yaml", "toml", "ini", "env":
|
|
|
|
|
return .init(symbol: "slider.horizontal.3", color: .mint)
|
|
|
|
|
case "html", "htm":
|
|
|
|
|
return .init(symbol: "chevron.left.slash.chevron.right", color: .orange)
|
|
|
|
|
case "css":
|
|
|
|
|
return .init(symbol: "paintbrush.pointed", color: .cyan)
|
|
|
|
|
case "xml", "svg":
|
|
|
|
|
return .init(symbol: "diamond", color: .pink)
|
|
|
|
|
case "sh", "bash", "zsh", "ps1":
|
|
|
|
|
return .init(symbol: "terminal", color: .indigo)
|
|
|
|
|
case "py":
|
|
|
|
|
return .init(symbol: "chevron.left.forwardslash.chevron.right", color: .yellow)
|
|
|
|
|
case "rb":
|
|
|
|
|
return .init(symbol: "diamond.fill", color: .red)
|
|
|
|
|
case "go":
|
|
|
|
|
return .init(symbol: "g.circle", color: .cyan)
|
|
|
|
|
case "rs":
|
|
|
|
|
return .init(symbol: "gearshape.2", color: .orange)
|
|
|
|
|
case "sql":
|
|
|
|
|
return .init(symbol: "cylinder", color: .purple)
|
|
|
|
|
case "csv", "tsv":
|
|
|
|
|
return .init(symbol: "tablecells", color: .green)
|
|
|
|
|
case "txt", "log":
|
|
|
|
|
return .init(symbol: "doc.plaintext", color: .secondary)
|
|
|
|
|
case "png", "jpg", "jpeg", "gif", "webp", "heic":
|
|
|
|
|
return .init(symbol: "photo", color: .purple)
|
|
|
|
|
case "pdf":
|
|
|
|
|
return .init(symbol: "doc.richtext", color: .red)
|
|
|
|
|
default:
|
|
|
|
|
if name.hasPrefix(".git") {
|
|
|
|
|
return .init(symbol: "arrow.triangle.branch", color: .orange)
|
|
|
|
|
}
|
|
|
|
|
if name.hasPrefix(".env") {
|
|
|
|
|
return .init(symbol: "lock.doc", color: .mint)
|
|
|
|
|
}
|
|
|
|
|
return .init(symbol: "doc.text", color: .secondary)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-06 18:59:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct ProjectTreeNode: Identifiable {
|
|
|
|
|
let url: URL
|
|
|
|
|
let isDirectory: Bool
|
|
|
|
|
var children: [ProjectTreeNode]
|
|
|
|
|
var id: String { url.path }
|
|
|
|
|
}
|