import SwiftUI import Foundation #if os(macOS) /// MARK: - Types 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 struct SidebarView: View { private struct TOCItem: Identifiable, Hashable { let id: String let title: String let line: Int? } let content: String let language: String let translucentBackgroundEnabled: Bool @Environment(\.colorScheme) private var colorScheme #if os(macOS) @AppStorage("SettingsMacTranslucencyMode") private var macTranslucencyModeRaw: String = "balanced" #endif @State private var tocItems: [TOCItem] = [ TOCItem(id: "empty", title: "No content available", line: nil) ] @State private var tocRefreshTask: Task? var body: some View { List { ForEach(tocItems) { item in Button { jump(to: item) } label: { Text(item.title) .font(.system(size: 13)) .foregroundColor(.primary) .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 8) .padding(.horizontal, 12) .background( RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(sidebarRowFill) ) } .buttonStyle(.plain) .disabled(item.line == nil) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } } .listStyle(platformListStyle) .scrollContentBackground(.hidden) .background(Color.clear) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .padding(sidebarOuterPaddingInsets) .background( RoundedRectangle(cornerRadius: sidebarCornerRadius, style: .continuous) .fill(sidebarSurfaceFill) .overlay( RoundedRectangle(cornerRadius: sidebarCornerRadius, style: .continuous) .stroke(sidebarSurfaceStroke, lineWidth: 1) ) ) .clipShape(RoundedRectangle(cornerRadius: sidebarCornerRadius, style: .continuous)) .onAppear { scheduleTOCRefresh() } .onChange(of: content) { _, _ in scheduleTOCRefresh() } .onChange(of: language) { _, _ in scheduleTOCRefresh() } .onDisappear { tocRefreshTask?.cancel() } } 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(currentEditorTheme(colorScheme: colorScheme).background) #else return AnyShapeStyle(currentEditorTheme(colorScheme: colorScheme).background) #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 colorScheme == .dark ? Color.white.opacity(0.06) : Color(red: 0.80, green: 0.88, blue: 1.0).opacity(0.55) #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 } 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) } } 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 } } } // Naive line-scanning TOC: looks for language-specific declarations or headers. private static func generateTableOfContents(content: String, language: String) -> [TOCItem] { guard !content.isEmpty else { return [TOCItem(id: "empty", title: "No content available", line: nil)] } if (content as NSString).length >= 400_000 { return [TOCItem(id: "large", title: "Large file detected: TOC disabled for performance", line: nil)] } let lines = content.components(separatedBy: .newlines) var toc: [TOCItem] = [] 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 ") { return TOCItem(id: "swift-\(index)", title: "\(trimmed) (Line \(index + 1))", line: index + 1) } return nil } case "python": toc = lines.enumerated().compactMap { index, line in let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("def ") || trimmed.hasPrefix("class ") { return TOCItem(id: "python-\(index)", title: "\(trimmed) (Line \(index + 1))", line: index + 1) } return nil } case "javascript": toc = lines.enumerated().compactMap { index, line in let trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("function ") || trimmed.hasPrefix("class ") { return TOCItem(id: "js-\(index)", title: "\(trimmed) (Line \(index + 1))", line: index + 1) } 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(")"))) { return TOCItem(id: "java-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1) } 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 ") { return TOCItem(id: "kotlin-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1) } return nil } case "go": toc = lines.enumerated().compactMap { index, line in let t = line.trimmingCharacters(in: .whitespaces) if t.hasPrefix("func ") || t.hasPrefix("type ") { return TOCItem(id: "go-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1) } 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 ") { return TOCItem(id: "ruby-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1) } 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 ") { return TOCItem(id: "rust-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1) } 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 ") { return TOCItem(id: "ts-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1) } return nil } 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 ") { return TOCItem(id: "php-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1) } return nil } 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{") { return TOCItem(id: "objc-\(index)", title: "\(t) (Line \(index + 1))", line: index + 1) } 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("{")) { return TOCItem(id: "c-\(index)", title: "\(trimmed) (Line \(index + 1))", line: index + 1) } 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 { return TOCItem(id: "sh-\(index)", title: "\(trimmed) (Line \(index + 1))", line: index + 1) } 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(") { return TOCItem(id: "ps-\(index)", title: "\(trimmed) (Line \(index + 1))", line: index + 1) } return nil } case "html", "css", "json", "markdown", "csv": toc = lines.enumerated().compactMap { index, line in let trimmed = line.trimmingCharacters(in: .whitespaces) if !trimmed.isEmpty && (trimmed.hasPrefix("#") || trimmed.hasPrefix(" Void let onOpenFolder: () -> Void let onToggleSupportedFilesOnly: (Bool) -> Void let onOpenProjectFile: (URL) -> Void let onRefreshTree: () -> Void let onCreateProjectFile: (URL?) -> Void let onCreateProjectFolder: (URL?) -> Void let onRenameProjectItem: (URL) -> Void let onDuplicateProjectItem: (URL) -> Void let onDeleteProjectItem: (URL) -> Void let revealURL: URL? @State private var expandedDirectories: Set = [] @State private var hoveredNodeID: String? = nil @Environment(\.colorScheme) private var colorScheme #if os(macOS) @AppStorage("SettingsMacTranslucencyMode") private var macTranslucencyModeRaw: String = "balanced" #endif @AppStorage("SettingsProjectSidebarDensity") private var sidebarDensityRaw: String = SidebarDensity.compact.rawValue @AppStorage("SettingsProjectSidebarAutoCollapseDeep") private var autoCollapseDeepFolders: Bool = true var body: some View { VStack(alignment: .leading, spacing: 0) { if showsSidebarActionsRow { VStack(alignment: .leading, spacing: isCompactDensity ? 8 : 10) { if showsInlineSidebarTitle { Text(NSLocalizedString("Project Structure", comment: "Project structure sidebar title")) .font(.system(size: isCompactDensity ? 19 : 20, weight: .semibold)) .lineLimit(1) .truncationMode(.tail) .layoutPriority(1) } HStack(spacing: isCompactDensity ? 10 : 12) { Button(action: onOpenFolder) { Image(systemName: "folder") } .buttonStyle(.borderless) .help(NSLocalizedString("Open Folder…", comment: "Project sidebar open folder action")) .accessibilityLabel(NSLocalizedString("Open folder", comment: "Project sidebar open folder accessibility label")) .accessibilityHint(NSLocalizedString("Select a project folder to show in the sidebar", comment: "Project sidebar open folder accessibility hint")) Button(action: onOpenFile) { Image(systemName: "doc") } .buttonStyle(.borderless) .help(NSLocalizedString("Open File…", comment: "Project sidebar open file action")) .accessibilityLabel(NSLocalizedString("Open file", comment: "Project sidebar open file accessibility label")) .accessibilityHint(NSLocalizedString("Opens a file from disk", comment: "Project sidebar open file accessibility hint")) Menu { Button { onCreateProjectFile(nil) } label: { Label(NSLocalizedString("New File", comment: "Project sidebar create file action"), systemImage: "doc.badge.plus") } Button { onCreateProjectFolder(nil) } label: { Label(NSLocalizedString("New Folder", comment: "Project sidebar create folder action"), systemImage: "folder.badge.plus") } } label: { Image(systemName: "plus") } .buttonStyle(.borderless) .help(NSLocalizedString("Create in Project Root", comment: "Project sidebar create action")) .accessibilityLabel(NSLocalizedString("Create project item", comment: "Project sidebar create accessibility label")) .accessibilityHint(NSLocalizedString("Creates a new file or folder in the project root", comment: "Project sidebar create accessibility hint")) Button(action: onRefreshTree) { Image(systemName: "arrow.clockwise") } .buttonStyle(.borderless) .help(NSLocalizedString("Refresh Folder Tree", comment: "Project sidebar refresh tree action")) .accessibilityLabel(NSLocalizedString("Refresh project tree", comment: "Project sidebar refresh accessibility label")) .accessibilityHint(NSLocalizedString("Reloads files and folders from disk", comment: "Project sidebar refresh accessibility hint")) Menu { Button { onToggleSupportedFilesOnly(!showSupportedFilesOnly) } label: { Label( NSLocalizedString("Show Supported Files Only", comment: "Project sidebar supported files filter label"), systemImage: showSupportedFilesOnly ? "checkmark.circle.fill" : "circle" ) } Divider() Picker(NSLocalizedString("Density", comment: "Project sidebar density picker label"), selection: $sidebarDensityRaw) { Text(NSLocalizedString("Compact", comment: "Project sidebar compact density")).tag(SidebarDensity.compact.rawValue) Text(NSLocalizedString("Comfortable", comment: "Project sidebar comfortable density")).tag(SidebarDensity.comfortable.rawValue) } Toggle(NSLocalizedString("Auto-collapse Deep Folders", comment: "Project sidebar auto-collapse deep folders toggle"), isOn: $autoCollapseDeepFolders) Divider() Button(NSLocalizedString("Expand All", comment: "Project sidebar expand all action")) { expandAllDirectories() } Button(NSLocalizedString("Collapse All", comment: "Project sidebar collapse all action")) { collapseAllDirectories() } } label: { Image(systemName: "arrow.up.arrow.down.circle") } .buttonStyle(.borderless) .help(NSLocalizedString("Expand or Collapse All", comment: "Project sidebar expand/collapse help")) .accessibilityLabel(NSLocalizedString("Expand or collapse all folders", comment: "Project sidebar expand/collapse accessibility label")) .accessibilityHint(NSLocalizedString("Expands or collapses all folders in the project tree", comment: "Project sidebar expand/collapse accessibility hint")) Spacer(minLength: 0) } } .padding(.horizontal, headerHorizontalPadding) .padding(.top, headerTopPadding) .padding(.bottom, headerBottomPadding) #if os(macOS) .background(sidebarHeaderFill) #endif } if let rootFolderURL { Text(rootFolderURL.path) .font(.system(size: isCompactDensity ? 11 : 12)) .foregroundStyle(.secondary) .lineLimit(isCompactDensity ? 1 : 2) .textSelection(.enabled) .contextMenu { Button { onCreateProjectFile(rootFolderURL) } label: { Label(NSLocalizedString("New File", comment: "Project sidebar create file action"), systemImage: "doc.badge.plus") } Button { onCreateProjectFolder(rootFolderURL) } label: { Label(NSLocalizedString("New Folder", comment: "Project sidebar create folder action"), systemImage: "folder.badge.plus") } } .padding(.horizontal, headerHorizontalPadding) .padding(.top, showsSidebarActionsRow ? 0 : headerTopPadding) .padding(.bottom, headerPathBottomPadding) } List { if rootFolderURL == nil { Text(NSLocalizedString("No folder selected", comment: "Project sidebar empty state without root folder")) .foregroundColor(.secondary) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } else if nodes.isEmpty { Text(NSLocalizedString("Folder is empty", comment: "Project sidebar empty state for selected folder")) .foregroundColor(.secondary) .listRowBackground(Color.clear) .listRowSeparator(.hidden) } else { ForEach(nodes) { node in projectNodeView(node, level: 0) } } } .listStyle(platformListStyle) .scrollContentBackground(.hidden) .background(Color.clear) .contextMenu { if let rootFolderURL { Button { onCreateProjectFile(rootFolderURL) } label: { Label(NSLocalizedString("New File", comment: "Project sidebar create file action"), systemImage: "doc.badge.plus") } Button { onCreateProjectFolder(rootFolderURL) } label: { Label(NSLocalizedString("New Folder", comment: "Project sidebar create folder action"), systemImage: "folder.badge.plus") } } } } .padding(sidebarOuterPadding) .background(sidebarContainerShape.fill(sidebarSurfaceFill)) .overlay(sidebarContainerBorderOverlay) .clipShape(sidebarContainerShape) .onAppear { revealTargetIfNeeded() } .onChange(of: revealPath) { _, _ in revealTargetIfNeeded() } .onChange(of: nodes.count) { _, _ in revealTargetIfNeeded() } #if os(macOS) .overlay(alignment: boundaryEdge == .leading ? .leading : .trailing) { if boundaryEdge != nil { 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(currentEditorTheme(colorScheme: colorScheme).background) #else return AnyShapeStyle(currentEditorTheme(colorScheme: colorScheme).background) #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) colorScheme == .dark ? Color.white.opacity(0.14) : Color.black.opacity(0.10) #else Color.black.opacity(0.1) #endif } private var sidebarCornerRadius: CGFloat { #if os(macOS) 18 #else 14 #endif } private var sidebarContainerShape: AnyShape { #if os(macOS) AnyShape(Rectangle()) #elseif os(iOS) if UIDevice.current.userInterfaceIdiom == .pad { AnyShape(Rectangle()) } else { AnyShape( UnevenRoundedRectangle( topLeadingRadius: 0, bottomLeadingRadius: sidebarCornerRadius, bottomTrailingRadius: sidebarCornerRadius, topTrailingRadius: 0, style: .continuous ) ) } #else AnyShape( UnevenRoundedRectangle( topLeadingRadius: 0, bottomLeadingRadius: sidebarCornerRadius, bottomTrailingRadius: sidebarCornerRadius, topTrailingRadius: 0, style: .continuous ) ) #endif } @ViewBuilder private var sidebarContainerBorderOverlay: some View { #if os(macOS) EmptyView() #elseif os(iOS) if UIDevice.current.userInterfaceIdiom != .pad { sidebarContainerShape.stroke(sidebarSurfaceStroke, lineWidth: 1) } #else sidebarContainerShape.stroke(sidebarSurfaceStroke, lineWidth: 1) #endif } private var sidebarOuterPadding: CGFloat { #if os(iOS) UIDevice.current.userInterfaceIdiom == .pad ? 0 : 10 #else 0 #endif } private var platformListStyle: some ListStyle { #if os(iOS) PlainListStyle() #else PlainListStyle() #endif } private func expandAllDirectories() { expandedDirectories = allDirectoryNodeIDs(in: nodes, level: 0) } private func collapseAllDirectories() { expandedDirectories.removeAll() } private func allDirectoryNodeIDs(in treeNodes: [ProjectTreeNode], level: Int) -> Set { var result: Set = [] for node in treeNodes where node.isDirectory { let shouldInclude = !autoCollapseDeepFolders || level < 2 if shouldInclude { result.insert(node.id) } result.formUnion(allDirectoryNodeIDs(in: node.children, level: level + 1)) } return result } private func projectNodeView(_ node: ProjectTreeNode, level: Int) -> AnyView { if node.isDirectory { let isHovered = hoveredNodeID == node.id 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: { HStack(spacing: directoryRowContentSpacing) { Image(systemName: "folder") .foregroundStyle(Color.accentColor) .symbolRenderingMode(.hierarchical) Text(node.url.lastPathComponent) .lineLimit(1) } .font(rowFont) .padding(.vertical, rowVerticalPadding) .padding(.trailing, rowHorizontalPadding) .padding(.leading, directoryRowContentLeadingPadding) .frame(maxWidth: .infinity, alignment: .leading) .background(rowChrome(isSelected: false, isHovered: isHovered)) .contentShape(Rectangle()) .accessibilityElement(children: .combine) .accessibilityLabel( Text( String( format: NSLocalizedString("Folder %@", comment: "Project sidebar folder accessibility label"), node.url.lastPathComponent ) ) ) } .padding(.leading, directoryRowLeadingInset(for: level)) .listRowInsets(rowInsets) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .contextMenu { Button { onCreateProjectFile(node.url) } label: { Label(NSLocalizedString("New File", comment: "Project sidebar create file action"), systemImage: "doc.badge.plus") } Button { onCreateProjectFolder(node.url) } label: { Label(NSLocalizedString("New Folder", comment: "Project sidebar create folder action"), systemImage: "folder.badge.plus") } Divider() Button { onRenameProjectItem(node.url) } label: { Label(NSLocalizedString("Rename", comment: "Project sidebar rename action"), systemImage: "pencil") } Button { onDuplicateProjectItem(node.url) } label: { Label(NSLocalizedString("Duplicate", comment: "Project sidebar duplicate action"), systemImage: "plus.square.on.square") } Divider() Button(role: .destructive) { onDeleteProjectItem(node.url) } label: { Label(NSLocalizedString("Delete", comment: "Project sidebar delete action"), systemImage: "trash") } } #if os(macOS) .onHover { hovering in if hovering { hoveredNodeID = node.id } else if hoveredNodeID == node.id { hoveredNodeID = nil } } #endif ) } else { let style = fileIconStyle(for: node.url) let isSelected = selectedFileURL?.standardizedFileURL == node.url.standardizedFileURL let isHovered = hoveredNodeID == node.id return AnyView( Button { onOpenProjectFile(node.url) } label: { HStack(spacing: 8) { Image(systemName: style.symbol) .foregroundStyle(style.color) .symbolRenderingMode(.hierarchical) Text(node.url.lastPathComponent) .lineLimit(1) Spacer() if isSelected { Image(systemName: "circle.fill") .font(.system(size: 7, weight: .semibold)) .foregroundColor(.white.opacity(colorScheme == .dark ? 0.92 : 0.98)) } } .font(rowFont) .foregroundStyle(isSelected ? rowSelectedForegroundColor : Color.primary) .padding(.vertical, rowVerticalPadding) .padding(.horizontal, rowHorizontalPadding) .frame(maxWidth: .infinity, alignment: .leading) .background(rowChrome(isSelected: isSelected, isHovered: isHovered)) .contentShape(Rectangle()) } .buttonStyle(.plain) .padding(.leading, CGFloat(level) * levelIndent) .listRowInsets(rowInsets) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .contextMenu { Button { onCreateProjectFile(node.url.deletingLastPathComponent()) } label: { Label(NSLocalizedString("New File Here", comment: "Project sidebar create file in same directory action"), systemImage: "doc.badge.plus") } Button { onCreateProjectFolder(node.url.deletingLastPathComponent()) } label: { Label(NSLocalizedString("New Folder Here", comment: "Project sidebar create folder in same directory action"), systemImage: "folder.badge.plus") } Divider() Button { onRenameProjectItem(node.url) } label: { Label(NSLocalizedString("Rename", comment: "Project sidebar rename action"), systemImage: "pencil") } Button { onDuplicateProjectItem(node.url) } label: { Label(NSLocalizedString("Duplicate", comment: "Project sidebar duplicate action"), systemImage: "plus.square.on.square") } Divider() Button(role: .destructive) { onDeleteProjectItem(node.url) } label: { Label(NSLocalizedString("Delete", comment: "Project sidebar delete action"), systemImage: "trash") } } .accessibilityLabel( Text( String( format: NSLocalizedString("File %@", comment: "Project sidebar file accessibility label"), node.url.lastPathComponent ) ) ) #if os(macOS) .onHover { hovering in if hovering { hoveredNodeID = node.id } else if hoveredNodeID == node.id { hoveredNodeID = nil } } #endif ) } } private var sidebarDensity: SidebarDensity { SidebarDensity(rawValue: sidebarDensityRaw) ?? .compact } private var isCompactDensity: Bool { sidebarDensity == .compact } private var levelIndent: CGFloat { isCompactDensity ? 10 : 13 } private var rowVerticalPadding: CGFloat { isCompactDensity ? 7 : 9 } private var rowHorizontalPadding: CGFloat { isCompactDensity ? 10 : 13 } private var directoryRowContentSpacing: CGFloat { #if os(macOS) isCompactDensity ? 4 : 5 #else isCompactDensity ? 6 : 7 #endif } private var directoryRowContentLeadingPadding: CGFloat { #if os(macOS) isCompactDensity ? 1 : 2 #else isCompactDensity ? 4 : 5 #endif } private var headerHorizontalPadding: CGFloat { isCompactDensity ? 16 : 18 } private var headerTopPadding: CGFloat { isCompactDensity ? 16 : 18 } private var headerBottomPadding: CGFloat { isCompactDensity ? 10 : 12 } private var headerPathBottomPadding: CGFloat { isCompactDensity ? 10 : 12 } private var rowInsets: EdgeInsets { EdgeInsets(top: 3, leading: isCompactDensity ? 11 : 13, bottom: 3, trailing: isCompactDensity ? 10 : 12) } private var showsInlineSidebarTitle: Bool { #if os(iOS) UIDevice.current.userInterfaceIdiom != .phone #else true #endif } private var showsSidebarActionsRow: Bool { true } private func directoryRowLeadingInset(for level: Int) -> CGFloat { let baseInset: CGFloat #if os(macOS) baseInset = level == 0 ? (isCompactDensity ? 12 : 14) : 0 #else baseInset = level == 0 ? (isCompactDensity ? 8 : 10) : 0 #endif return baseInset + CGFloat(level) * levelIndent } private var rowFont: Font { .system(size: isCompactDensity ? 13 : 14, weight: .medium) } private var rowSelectedForegroundColor: Color { colorScheme == .dark ? .white : .primary } private func rowChrome(isSelected: Bool, isHovered: Bool) -> some View { RoundedRectangle(cornerRadius: isCompactDensity ? 12 : 14, style: .continuous) .fill(rowFill(isSelected: isSelected, isHovered: isHovered)) } private func rowFill(isSelected: Bool, isHovered: Bool) -> Color { if isSelected { return selectedRowFill } if isHovered { return hoveredRowFill } return unselectedRowFill } private var selectedRowFill: Color { if colorScheme == .dark { return Color.accentColor.opacity(0.48) } return Color.accentColor.opacity(0.22) } private var hoveredRowFill: Color { if colorScheme == .dark { return Color.white.opacity(0.08) } return Color.black.opacity(0.07) } private var unselectedRowFill: Color { colorScheme == .dark ? Color.white.opacity(0.028) : Color.black.opacity(0.024) } private var revealPath: String? { revealURL?.standardizedFileURL.path } private func revealTargetIfNeeded() { guard let revealPath else { return } guard let pathIDs = directoryPathIDs(for: revealPath, in: nodes) else { return } expandedDirectories.formUnion(pathIDs) } private func directoryPathIDs(for targetPath: String, in treeNodes: [ProjectTreeNode]) -> [String]? { for node in treeNodes { if let path = directoryPathIDs(for: targetPath, node: node) { return path } } return nil } private func directoryPathIDs(for targetPath: String, node: ProjectTreeNode) -> [String]? { let nodePath = node.url.standardizedFileURL.path if nodePath == targetPath { return node.isDirectory ? [node.id] : [] } guard node.isDirectory else { return nil } for child in node.children { if let childPath = directoryPathIDs(for: targetPath, node: child) { return [node.id] + childPath } } return nil } 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 "tex", "latex", "bib", "sty", "cls": return .init(symbol: "text.book.closed", color: .indigo) 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) } } } struct ProjectTreeNode: Identifiable { let url: URL let isDirectory: Bool var children: [ProjectTreeNode] var id: String { url.path } }