From 52df0c53a39cc191a3fa4fbabc8ce73b16864aa2 Mon Sep 17 00:00:00 2001 From: yan Date: Fri, 9 Oct 2026 20:17:37 +0300 Subject: [PATCH 01/22] chore(app): script the .app bundle build and the app icon SwiftPM produces a bare Mach-O binary. macOS needs the bundle layout before the process gets a Dock tile, a menu bar and an open-panel identity. build-app.sh lays out Contents/{MacOS,Resources}, embeds the SwiftPM resource bundles Bundle.module resolves through Bundle.main.resourceURL, renders AppIcon.icns from make-icon.swift and ad-hoc signs the result. Verified: Scripts/build-app.sh produces a valid, launching 105 MB bundle from a clean release build. --- README.md | 24 +++++++++ Resources/App-Info.plist | 38 +++++++++++++ Scripts/build-app.sh | 98 +++++++++++++++++++++++++++++++++ Scripts/make-icon.swift | 113 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 273 insertions(+) create mode 100644 Resources/App-Info.plist create mode 100755 Scripts/build-app.sh create mode 100644 Scripts/make-icon.swift diff --git a/README.md b/README.md index b9ecdc1..0f17e36 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,30 @@ Run the tests with: swift test ``` +### Build the .app bundle + +`swift run` gives you the binary, not an application — no Dock tile, no menu bar, no +double-clickable file. To get `build/Code Editor.app`, run: + +```bash +Scripts/build-app.sh +``` + +The script builds in release, lays out `Contents/{MacOS,Resources}`, copies every SwiftPM +resource bundle into `Contents/Resources` (CodeEditLanguages grammars and CodeEditSymbols +assets are resolved through `Bundle.module`, which looks them up under `Bundle.main.resourceURL`), +renders `AppIcon.icns` from `Scripts/make-icon.swift`, and ad-hoc signs the bundle. + +| Flag | Effect | +|---|---| +| `--debug` | Build the debug product instead of release | +| `--install` | Also copy the bundle to `/Applications` | +| `--open` | Launch it when the build finishes | + +Ad-hoc signing is enough locally and for handing the bundle to another Mac. To distribute it +further, swap the `codesign` identity in the script for a Developer ID and notarize the zipped +bundle. + Format and lint with `swift-format`, which ships inside the Xcode toolchain: ```bash diff --git a/Resources/App-Info.plist b/Resources/App-Info.plist new file mode 100644 index 0000000..b22b5b6 --- /dev/null +++ b/Resources/App-Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + CodeEditorApp + CFBundleIconFile + AppIcon + CFBundleIdentifier + com.yan123tech.codeeditor + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Code Editor + CFBundleDisplayName + Code Editor + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 0.1.0 + LSApplicationCategoryType + public.app-category.developer-tools + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSPrincipalClass + NSApplication + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + + diff --git a/Scripts/build-app.sh b/Scripts/build-app.sh new file mode 100755 index 0000000..da4f57c --- /dev/null +++ b/Scripts/build-app.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# +# Assembles build/Code Editor.app from the SwiftPM release binary. +# +# Scripts/build-app.sh [--debug] [--install] [--open] +# +# SwiftPM produces a bare Mach-O executable. macOS needs the bundle layout — +# Info.plist plus Contents/MacOS — before it will give the process a Dock tile, +# a menu bar and a document-based open-panel identity. This script builds the +# release binary, lays out the bundle, copies in every SwiftPM resource bundle +# (CodeEditLanguages grammars, CodeEditSymbols assets) and ad-hoc signs the +# result so it launches without a Gatekeeper prompt. +# +# Ad-hoc signing is enough for local use and for handing the bundle to another +# Mac. For distribution beyond the machine that built it, replace the signing +# identity with a Developer ID and notarize the zipped bundle. +set -euo pipefail + +CONFIGURATION="release" +INSTALL=0 +LAUNCH=0 + +for argument in "$@"; do + case "$argument" in + --debug) CONFIGURATION="debug" ;; + --install) INSTALL=1 ;; + --open) LAUNCH=1 ;; + *) echo "unknown option: $argument" >&2; exit 2 ;; + esac +done + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP_NAME="Code Editor" +BUNDLE_ID="com.yan123tech.codeeditor" +DESTINATION="$ROOT/build/$APP_NAME.app" +CONTENTS="$DESTINATION/Contents" +EXECUTABLE="CodeEditorApp" + +cd "$ROOT" + +echo "==> Building $EXECUTABLE ($CONFIGURATION)" +swift build -c "$CONFIGURATION" --product "$EXECUTABLE" --show-bin-path > /dev/null +BIN_PATH="$(swift build -c "$CONFIGURATION" --product "$EXECUTABLE" --show-bin-path)" + +echo "==> Laying out $DESTINATION" +rm -rf "$DESTINATION" +mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources" + +install -m 755 "$BIN_PATH/$EXECUTABLE" "$CONTENTS/MacOS/$EXECUTABLE" +install -m 644 "$ROOT/Resources/App-Info.plist" "$CONTENTS/Info.plist" + +# SwiftPM writes resource bundles next to the binary and Bundle.module looks +# them up under Bundle.main.resourceURL, which is Contents/Resources here. +shopt -s nullglob +for bundle in "$BIN_PATH"/*.bundle; do + echo "==> Embedding $(basename "$bundle")" + cp -R "$bundle" "$CONTENTS/Resources/" +done +shopt -u nullglob + +echo "==> Rendering icon" +TEMP_DIR="$(mktemp -d -t code-editor-app)" +trap 'rm -rf "$TEMP_DIR"' EXIT +ICON_PNG="$TEMP_DIR/AppIcon.png" +ICONSET="$TEMP_DIR/AppIcon.iconset" +mkdir -p "$ICONSET" + +swift "$ROOT/Scripts/make-icon.swift" "$ICON_PNG" + +for size in 16 32 128 256 512; do + sips -z "$size" "$size" "$ICON_PNG" --out "$ICONSET/icon_${size}x${size}.png" > /dev/null + double=$((size * 2)) + sips -z "$double" "$double" "$ICON_PNG" --out "$ICONSET/icon_${size}x${size}@2x.png" > /dev/null +done +iconutil -c icns "$ICONSET" -o "$CONTENTS/Resources/AppIcon.icns" + +echo "==> Signing (ad-hoc)" +codesign --force --sign - --timestamp=none "$DESTINATION" +codesign --verify --verbose=2 "$DESTINATION" + +# LaunchServices caches the bundle identity, so a rebuild can keep showing the +# previous icon or menu-bar name until this runs. +touch "$DESTINATION" + +echo "==> Built $DESTINATION" +if [[ "$INSTALL" == "1" ]]; then + rm -rf "/Applications/$APP_NAME.app" + cp -R "$DESTINATION" /Applications/ + echo "==> Installed /Applications/$APP_NAME.app" + DESTINATION="/Applications/$APP_NAME.app" +fi + +if [[ "$LAUNCH" == "1" ]]; then + open "$DESTINATION" +fi + +plutil -p "$CONTENTS/Info.plist" | grep -E "CFBundleIdentifier|CFBundleShortVersionString|CFBundleExecutable" +du -sh "$DESTINATION" diff --git a/Scripts/make-icon.swift b/Scripts/make-icon.swift new file mode 100644 index 0000000..16455b7 --- /dev/null +++ b/Scripts/make-icon.swift @@ -0,0 +1,113 @@ +// Renders the source PNG for AppIcon.icns. +// +// swift Scripts/make-icon.swift +// +// The script draws a 1024pt squircle with a violet gradient, a monospace +// "" and a caret bar, then writes a single PNG. build-app.sh downsamples it +// into an .iconset and runs iconutil. +import AppKit + +let dimension = 1024 +let outputPath = CommandLine.arguments.dropFirst().first ?? "AppIcon.png" + +guard + let bitmap = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: dimension, + pixelsHigh: dimension, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + ) +else { + fatalError("could not allocate bitmap") +} + +NSGraphicsContext.saveGraphicsState() +NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap) +let context = NSGraphicsContext.current!.cgContext + +let side = CGFloat(dimension) +let margin: CGFloat = 96 +let squircle = CGRect( + x: margin, + y: margin, + width: side - margin * 2, + height: side - margin * 2 +) + +// Gradient plate, clipped to the squircle. +context.saveGState() +context.addPath(CGPath(roundedRect: squircle, cornerWidth: 224, cornerHeight: 224, transform: nil)) +context.clip() + +let space = CGColorSpaceCreateDeviceRGB() +let plate = CGGradient( + colorsSpace: space, + colors: [ + NSColor(calibratedRed: 0.36, green: 0.20, blue: 0.78, alpha: 1).cgColor, + NSColor(calibratedRed: 0.62, green: 0.32, blue: 0.92, alpha: 1).cgColor, + ] as CFArray, + locations: [0, 1] +)! +context.drawLinearGradient( + plate, + start: CGPoint(x: squircle.minX, y: squircle.maxY), + end: CGPoint(x: squircle.maxX, y: squircle.minY), + options: [] +) + +// Soft highlight so the plate reads as glass rather than flat fill. +let highlight = CGGradient( + colorsSpace: space, + colors: [ + NSColor(white: 1, alpha: 0.22).cgColor, + NSColor(white: 1, alpha: 0).cgColor, + ] as CFArray, + locations: [0, 1] +)! +context.drawRadialGradient( + highlight, + startCenter: CGPoint(x: squircle.midX - 160, y: squircle.maxY - 140), + startRadius: 0, + endCenter: CGPoint(x: squircle.midX - 160, y: squircle.maxY - 140), + endRadius: 620, + options: [] +) +context.restoreGState() + +// Glyph. +let glyph = NSAttributedString( + string: "", + attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: 292, weight: .bold), + .foregroundColor: NSColor.white, + ] +) +let glyphSize = glyph.size() +glyph.draw( + at: CGPoint( + x: squircle.midX - glyphSize.width / 2, + y: squircle.midY - glyphSize.height / 2 - 10 + ) +) + +// Caret bar. +let caret = NSBezierPath( + roundedRect: CGRect(x: squircle.midX - 66, y: squircle.midY - 268, width: 132, height: 28), + xRadius: 14, + yRadius: 14 +) +NSColor(calibratedRed: 0.63, green: 0.95, blue: 0.79, alpha: 1).setFill() +caret.fill() + +NSGraphicsContext.restoreGraphicsState() + +guard let png = bitmap.representation(using: .png, properties: [:]) else { + fatalError("could not encode PNG") +} +try png.write(to: URL(fileURLWithPath: outputPath)) From bb7f278c3613b700c254b245df7b936a323b4976 Mon Sep 17 00:00:00 2001 From: yan Date: Fri, 9 Oct 2026 20:21:08 +0300 Subject: [PATCH 02/22] fix(themes): derive appearance from the palette, not channel arithmetic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isDark compared background.red < background.blue, which is false for any neutral background: #1e1e1e is 30 < 30. Dark and High Contrast therefore forced .preferredColorScheme(.light), so every system-drawn control — sidebar rows, sheets, buttons, open panels — rendered light against a dark canvas. Theme now declares colorScheme per palette and isDark reads it. Adds CodeEditorThemes to the test target; the suite fails against the old comparison. --- Package.swift | 2 +- Sources/CodeEditorThemes/ThemeManager.swift | 27 +++++++-- Tests/CodeEditorTests/ThemeManagerTests.swift | 55 +++++++++++++++++++ 3 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 Tests/CodeEditorTests/ThemeManagerTests.swift diff --git a/Package.swift b/Package.swift index 7de6329..fd3a908 100644 --- a/Package.swift +++ b/Package.swift @@ -82,7 +82,7 @@ let package = Package( // MARK: - Tests .testTarget( name: "CodeEditorTests", - dependencies: ["CodeEditorCore", "CodeEditorTerminal", "CodeEditorLSP"], + dependencies: ["CodeEditorCore", "CodeEditorTerminal", "CodeEditorLSP", "CodeEditorThemes"], path: "Tests/CodeEditorTests", swiftSettings: [.enableUpcomingFeature("ApproachableConcurrency")] ) diff --git a/Sources/CodeEditorThemes/ThemeManager.swift b/Sources/CodeEditorThemes/ThemeManager.swift index de7e473..82a8515 100644 --- a/Sources/CodeEditorThemes/ThemeManager.swift +++ b/Sources/CodeEditorThemes/ThemeManager.swift @@ -65,6 +65,11 @@ public struct Theme: Sendable, Equatable { public var function: EditorColor public var property: EditorColor + /// The system appearance this palette should force on the window, so materials + /// and system controls agree with the canvas. Declared per palette rather than + /// derived — see `ThemeManager.isDark` for why deriving does not work. + public var colorScheme: ColorScheme + public init( background: EditorColor, text: EditorColor, @@ -82,7 +87,8 @@ public struct Theme: Sendable, Equatable { type: EditorColor, variable: EditorColor, function: EditorColor, - property: EditorColor + property: EditorColor, + colorScheme: ColorScheme = .dark ) { self.background = background self.text = text @@ -101,6 +107,7 @@ public struct Theme: Sendable, Equatable { self.variable = variable self.function = function self.property = property + self.colorScheme = colorScheme } /// One Dark Plus-ish palette. @@ -121,7 +128,8 @@ public struct Theme: Sendable, Equatable { type: EditorColor(hex: "#4ec9b0"), variable: EditorColor(hex: "#9cdcfe"), function: EditorColor(hex: "#dcdcaa"), - property: EditorColor(hex: "#d4d4d4") + property: EditorColor(hex: "#d4d4d4"), + colorScheme: .dark ) /// A light counterpart to `dark`. @@ -142,7 +150,8 @@ public struct Theme: Sendable, Equatable { type: EditorColor(hex: "#267f99"), variable: EditorColor(hex: "#001080"), function: EditorColor(hex: "#795e26"), - property: EditorColor(hex: "#1f1f1f") + property: EditorColor(hex: "#1f1f1f"), + colorScheme: .light ) /// Maximum contrast, for accessibility. @@ -163,7 +172,8 @@ public struct Theme: Sendable, Equatable { type: EditorColor(hex: "#66ccff"), variable: EditorColor(hex: "#ffffff"), function: EditorColor(hex: "#ff99cc"), - property: EditorColor(hex: "#ffffff") + property: EditorColor(hex: "#ffffff"), + colorScheme: .dark ) public static let all: [Theme] = [.dark, .light, .highContrast] @@ -209,8 +219,15 @@ public final class ThemeManager { Theme.all.first { $0.name.caseInsensitiveCompare(name) == .orderedSame } } + /// Whether the palette forces a dark system appearance. + /// + /// Reads the declared `colorScheme`. The previous implementation compared the + /// background's red and blue channels, which returns `false` for any neutral + /// background — `#1e1e1e` is `30 < 30` — so the Dark and High Contrast palettes + /// rendered all system chrome in light mode. Do not derive this from colour + /// arithmetic again. public var isDark: Bool { - currentTheme.background.red < currentTheme.background.blue + currentTheme.colorScheme == .dark } } diff --git a/Tests/CodeEditorTests/ThemeManagerTests.swift b/Tests/CodeEditorTests/ThemeManagerTests.swift new file mode 100644 index 0000000..207872c --- /dev/null +++ b/Tests/CodeEditorTests/ThemeManagerTests.swift @@ -0,0 +1,55 @@ +import CodeEditorThemes +import SwiftUI +import Testing + +@Suite("ThemeManager") +@MainActor +struct ThemeManagerTests { + @Test("Every palette declares the appearance it forces") + func appearanceMatchesPalette() { + let manager = ThemeManager(theme: .dark) + #expect(manager.isDark == true) + + manager.setTheme(.light) + #expect(manager.isDark == false) + + manager.setTheme(.highContrast) + #expect(manager.isDark == true) + } + + @Test("Dark and High Contrast are not misread as light") + func neutralBackgroundsAreNotMisread() { + // Regression: the old isDark compared background.red < background.blue, + // which is false for any neutral background such as #1e1e1e or #000000, + // forcing system chrome into light mode under a dark canvas. + #expect(Theme.dark.colorScheme == .dark) + #expect(Theme.highContrast.colorScheme == .dark) + #expect(Theme.light.colorScheme == .light) + } + + @Test("Persistence round-trips the theme name") + func persistenceRoundTrips() throws { + let suiteName = "ThemeManagerTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + + let manager = ThemeManager(theme: .dark) + manager.setTheme(.highContrast, defaults: defaults) + + let restored = ThemeManager(theme: .dark) + restored.loadPersistedTheme(defaults: defaults) + #expect(restored.currentTheme == .highContrast) + } + + @Test("An unknown persisted name keeps the default") + func unknownPersistedNameIsIgnored() { + let suiteName = "ThemeManagerTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set("Solarized", forKey: "CodeEditor.selectedTheme") + + let manager = ThemeManager(theme: .light) + manager.loadPersistedTheme(defaults: defaults) + #expect(manager.currentTheme == .light) + } +} From 1f50869ccbd00ae4ef42fa4b29190dab6b86b92c Mon Sep 17 00:00:00 2001 From: yan Date: Fri, 9 Oct 2026 20:23:03 +0300 Subject: [PATCH 03/22] feat(themes): add chrome and semantic token groups Theme gains a Chrome group (accent, sidebar, rows, bars, panels, overlays, borders, usesMaterials) and a Semantic group (danger, success, warning). statusBarBackground and separator move into chrome.barBackground and chrome.border. The accent was previously theme.keyword, so the tab underline, folder icons, terminal prompt and terminal errors all changed meaning with the syntax palette. Each of those now reads a chrome or semantic token that means one thing. The terminal connection dot uses semantic success/danger instead of raw Color.green/.red. CodeEditorThemes splits into Theme, Chrome, EditorColor and a slimmer ThemeManager, one type per file. --- .../Sources/MainWindowView.swift | 10 +- Sources/CodeEditorThemes/Sources/Chrome.swift | 92 +++++++++ .../Sources/EditorColor.swift | 47 +++++ .../Theme.swift} | 186 +++++++----------- .../Sources/ThemeManager.swift | 53 +++++ .../Sources/Sidebar/FileExplorerView.swift | 4 +- .../Sources/Terminal/TerminalView.swift | 10 +- 7 files changed, 279 insertions(+), 123 deletions(-) create mode 100644 Sources/CodeEditorThemes/Sources/Chrome.swift create mode 100644 Sources/CodeEditorThemes/Sources/EditorColor.swift rename Sources/CodeEditorThemes/{ThemeManager.swift => Sources/Theme.swift} (53%) create mode 100644 Sources/CodeEditorThemes/Sources/ThemeManager.swift diff --git a/Sources/CodeEditorApp/Sources/MainWindowView.swift b/Sources/CodeEditorApp/Sources/MainWindowView.swift index f8b4152..c599d99 100644 --- a/Sources/CodeEditorApp/Sources/MainWindowView.swift +++ b/Sources/CodeEditorApp/Sources/MainWindowView.swift @@ -29,7 +29,7 @@ struct MainWindowView: View { } if appState.isTerminalVisible { - Divider().overlay(appState.theme.separator.color) + Divider().overlay(appState.theme.chrome.border.color) TerminalView(terminal: appState.terminal, theme: appState.theme) .frame(height: 220) } @@ -37,7 +37,7 @@ struct MainWindowView: View { .background(appState.theme.background.color) } .background(appState.theme.background.color) - .tint(appState.theme.keyword.color) + .tint(appState.theme.chrome.accent.color) .preferredColorScheme(appState.themeManager.isDark ? .dark : .light) .alert("Code Editor", isPresented: errorBinding) { Button("OK", role: .cancel) { appState.errorMessage = nil } @@ -110,7 +110,7 @@ private struct TabBar: View { .help("New File (⌘N)") } .frame(height: 28) - .background(appState.theme.statusBarBackground.color) + .background(appState.theme.chrome.barBackground.color) } private func tab(for document: CodeEditorCore.Document) -> some View { @@ -146,7 +146,7 @@ private struct TabBar: View { .background(isActive ? appState.theme.background.color : .clear) .overlay(alignment: .bottom) { Rectangle() - .fill(isActive ? appState.theme.keyword.color : .clear) + .fill(isActive ? appState.theme.chrome.accent.color : .clear) .frame(height: 1) } } @@ -184,7 +184,7 @@ private struct StatusBar: View { .padding(.horizontal, 10) .padding(.vertical, 3) .frame(height: 20) - .background(appState.theme.statusBarBackground.color) + .background(appState.theme.chrome.barBackground.color) } } diff --git a/Sources/CodeEditorThemes/Sources/Chrome.swift b/Sources/CodeEditorThemes/Sources/Chrome.swift new file mode 100644 index 0000000..12ef606 --- /dev/null +++ b/Sources/CodeEditorThemes/Sources/Chrome.swift @@ -0,0 +1,92 @@ +import SwiftUI + +// MARK: - Chrome + +/// Tokens for the window chrome around the code canvas: sidebar, bars, panels, +/// overlays, and the app-wide accent. +/// +/// These exist so chrome colors stop being borrowed from syntax tokens. The accent +/// used to be `Theme.keyword`, which meant the tab underline, the folder icons, the +/// terminal prompt and terminal *errors* all changed meaning whenever the syntax +/// palette did. A chrome token means one thing and is tuned for chrome. +/// +/// When `usesMaterials` is true, views should prefer system materials over these +/// fills — the tokens then act as fallbacks and as the source for derived states +/// such as hover washes. High Contrast sets the flag to false and uses the fills +/// directly, because translucency trades contrast for depth. +public struct Chrome: Sendable, Equatable { + /// The one saturated color the app may use for selection, focus and links. + public var accent: EditorColor + + /// Fallback fill for the sidebar when materials are off. + public var sidebarBackground: EditorColor + + /// Row hover wash in lists and trees. + public var rowHover: EditorColor + + /// Fill for the selected row, typically the accent at low alpha. + public var rowSelected: EditorColor + + /// Fallback fill for the tab strip and status bar when materials are off. + public var barBackground: EditorColor + + /// Fill of the active tab. Equal to the canvas so the tab merges into it. + public var tabActiveBackground: EditorColor + + /// Hover wash for inactive tabs. + public var tabHoverBackground: EditorColor + + /// Fill for docked panels such as the terminal. + public var panelBackground: EditorColor + + /// Fill for floating chrome: find bar, quick open, popovers. + public var overlayBackground: EditorColor + + /// Hairlines and separators. + public var border: EditorColor + + /// Whether views should prefer system materials over the fills above. + public var usesMaterials: Bool + + public init( + accent: EditorColor, + sidebarBackground: EditorColor, + rowHover: EditorColor, + rowSelected: EditorColor, + barBackground: EditorColor, + tabActiveBackground: EditorColor, + tabHoverBackground: EditorColor, + panelBackground: EditorColor, + overlayBackground: EditorColor, + border: EditorColor, + usesMaterials: Bool = true + ) { + self.accent = accent + self.sidebarBackground = sidebarBackground + self.rowHover = rowHover + self.rowSelected = rowSelected + self.barBackground = barBackground + self.tabActiveBackground = tabActiveBackground + self.tabHoverBackground = tabHoverBackground + self.panelBackground = panelBackground + self.overlayBackground = overlayBackground + self.border = border + self.usesMaterials = usesMaterials + } +} + +// MARK: - Semantic + +/// Status colors that mean the same thing everywhere they appear: connection state, +/// terminal errors, and later diagnostics. +public struct Semantic: Sendable, Equatable { + public var danger: EditorColor + public var success: EditorColor + public var warning: EditorColor + + public init(danger: EditorColor, success: EditorColor, warning: EditorColor) { + self.danger = danger + self.success = success + self.warning = warning + } +} diff --git a/Sources/CodeEditorThemes/Sources/EditorColor.swift b/Sources/CodeEditorThemes/Sources/EditorColor.swift new file mode 100644 index 0000000..913c9ac --- /dev/null +++ b/Sources/CodeEditorThemes/Sources/EditorColor.swift @@ -0,0 +1,47 @@ +import SwiftUI + +// MARK: - EditorColor + +/// An sRGB color that can be declared from hex in code and handed to AppKit or SwiftUI. +public struct EditorColor: Sendable, Equatable, Hashable { + public let red: Double + public let green: Double + public let blue: Double + public let alpha: Double + + public init(red: Double, green: Double, blue: Double, alpha: Double = 1) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + } + + /// Parse `#rrggbb` or `#rrggbbaa`. Falls back to black on malformed input. + public init(hex: String) { + var value = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if value.hasPrefix("#") { value.removeFirst() } + + var number: UInt64 = 0 + guard value.count == 6 || value.count == 8, Scanner(string: value).scanHexInt64(&number) else { + self.init(red: 0, green: 0, blue: 0) + return + } + + let hasAlpha = value.count == 8 + self.init( + red: Double((number >> (hasAlpha ? 24 : 16)) & 0xFF) / 255, + green: Double((number >> (hasAlpha ? 16 : 8)) & 0xFF) / 255, + blue: Double((number >> (hasAlpha ? 8 : 0)) & 0xFF) / 255, + alpha: hasAlpha ? Double(number & 0xFF) / 255 : 1 + ) + } + + /// The same color at a different opacity, for selected-row fills and hover washes. + public func withAlpha(_ alpha: Double) -> EditorColor { + EditorColor(red: red, green: green, blue: blue, alpha: alpha) + } + + public var color: Color { Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha) } + + public var nsColor: NSColor { NSColor(srgbRed: red, green: green, blue: blue, alpha: alpha) } +} diff --git a/Sources/CodeEditorThemes/ThemeManager.swift b/Sources/CodeEditorThemes/Sources/Theme.swift similarity index 53% rename from Sources/CodeEditorThemes/ThemeManager.swift rename to Sources/CodeEditorThemes/Sources/Theme.swift index 82a8515..c2c0cc6 100644 --- a/Sources/CodeEditorThemes/ThemeManager.swift +++ b/Sources/CodeEditorThemes/Sources/Theme.swift @@ -1,49 +1,12 @@ import SwiftUI -// MARK: - EditorColor - -/// An sRGB color that can be declared from hex in code and handed to AppKit or SwiftUI. -public struct EditorColor: Sendable, Equatable, Hashable { - public let red: Double - public let green: Double - public let blue: Double - public let alpha: Double - - public init(red: Double, green: Double, blue: Double, alpha: Double = 1) { - self.red = red - self.green = green - self.blue = blue - self.alpha = alpha - } - - /// Parse `#rrggbb` or `#rrggbbaa`. Falls back to black on malformed input. - public init(hex: String) { - var value = hex.trimmingCharacters(in: .whitespacesAndNewlines) - if value.hasPrefix("#") { value.removeFirst() } - - var number: UInt64 = 0 - guard value.count == 6 || value.count == 8, Scanner(string: value).scanHexInt64(&number) else { - self.init(red: 0, green: 0, blue: 0) - return - } - - let hasAlpha = value.count == 8 - self.init( - red: Double((number >> (hasAlpha ? 24 : 16)) & 0xFF) / 255, - green: Double((number >> (hasAlpha ? 16 : 8)) & 0xFF) / 255, - blue: Double((number >> (hasAlpha ? 8 : 0)) & 0xFF) / 255, - alpha: hasAlpha ? Double(number & 0xFF) / 255 : 1 - ) - } - - public var color: Color { Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha) } - - public var nsColor: NSColor { NSColor(srgbRed: red, green: green, blue: blue, alpha: alpha) } -} - // MARK: - Theme -/// Colors used by the editor chrome and for syntax highlighting. +/// The palette for one appearance: the code canvas, the syntax tokens, the chrome +/// around them, and the system appearance the window should force. +/// +/// The canvas and syntax fields feed `EditorTheme` through the bridge in +/// `CodeEditorUI`; the `chrome` and `semantic` groups feed the window chrome. public struct Theme: Sendable, Equatable { public var background: EditorColor public var text: EditorColor @@ -52,8 +15,6 @@ public struct Theme: Sendable, Equatable { public var currentLine: EditorColor public var selection: EditorColor public var cursor: EditorColor - public var statusBarBackground: EditorColor - public var separator: EditorColor // Syntax highlighting public var keyword: EditorColor @@ -70,6 +31,12 @@ public struct Theme: Sendable, Equatable { /// derived — see `ThemeManager.isDark` for why deriving does not work. public var colorScheme: ColorScheme + /// Window chrome tokens. See `Chrome`. + public var chrome: Chrome + + /// Status colors. See `Semantic`. + public var semantic: Semantic + public init( background: EditorColor, text: EditorColor, @@ -78,8 +45,6 @@ public struct Theme: Sendable, Equatable { currentLine: EditorColor, selection: EditorColor, cursor: EditorColor, - statusBarBackground: EditorColor, - separator: EditorColor, keyword: EditorColor, string: EditorColor, comment: EditorColor, @@ -88,7 +53,9 @@ public struct Theme: Sendable, Equatable { variable: EditorColor, function: EditorColor, property: EditorColor, - colorScheme: ColorScheme = .dark + colorScheme: ColorScheme = .dark, + chrome: Chrome, + semantic: Semantic ) { self.background = background self.text = text @@ -97,8 +64,6 @@ public struct Theme: Sendable, Equatable { self.currentLine = currentLine self.selection = selection self.cursor = cursor - self.statusBarBackground = statusBarBackground - self.separator = separator self.keyword = keyword self.string = string self.comment = comment @@ -108,6 +73,8 @@ public struct Theme: Sendable, Equatable { self.function = function self.property = property self.colorScheme = colorScheme + self.chrome = chrome + self.semantic = semantic } /// One Dark Plus-ish palette. @@ -119,8 +86,6 @@ public struct Theme: Sendable, Equatable { currentLine: EditorColor(hex: "#2a2a2a"), selection: EditorColor(hex: "#264f78"), cursor: EditorColor(hex: "#aeafad"), - statusBarBackground: EditorColor(hex: "#252526"), - separator: EditorColor(hex: "#3c3c3c"), keyword: EditorColor(hex: "#569cd6"), string: EditorColor(hex: "#ce9178"), comment: EditorColor(hex: "#6a9955"), @@ -129,7 +94,25 @@ public struct Theme: Sendable, Equatable { variable: EditorColor(hex: "#9cdcfe"), function: EditorColor(hex: "#dcdcaa"), property: EditorColor(hex: "#d4d4d4"), - colorScheme: .dark + colorScheme: .dark, + chrome: Chrome( + accent: EditorColor(hex: "#4c8dff"), + sidebarBackground: EditorColor(hex: "#181818"), + rowHover: EditorColor(hex: "#262626"), + rowSelected: EditorColor(hex: "#4c8dff2e"), + barBackground: EditorColor(hex: "#252526"), + tabActiveBackground: EditorColor(hex: "#1e1e1e"), + tabHoverBackground: EditorColor(hex: "#2a2a2b"), + panelBackground: EditorColor(hex: "#1b1b1b"), + overlayBackground: EditorColor(hex: "#242424"), + border: EditorColor(hex: "#3c3c3c"), + usesMaterials: true + ), + semantic: Semantic( + danger: EditorColor(hex: "#ff6b6b"), + success: EditorColor(hex: "#5bd675"), + warning: EditorColor(hex: "#e3b341") + ) ) /// A light counterpart to `dark`. @@ -141,8 +124,6 @@ public struct Theme: Sendable, Equatable { currentLine: EditorColor(hex: "#f3f3f3"), selection: EditorColor(hex: "#add6ff"), cursor: EditorColor(hex: "#000000"), - statusBarBackground: EditorColor(hex: "#f3f3f3"), - separator: EditorColor(hex: "#d4d4d4"), keyword: EditorColor(hex: "#0000ff"), string: EditorColor(hex: "#a31515"), comment: EditorColor(hex: "#008000"), @@ -151,10 +132,29 @@ public struct Theme: Sendable, Equatable { variable: EditorColor(hex: "#001080"), function: EditorColor(hex: "#795e26"), property: EditorColor(hex: "#1f1f1f"), - colorScheme: .light + colorScheme: .light, + chrome: Chrome( + accent: EditorColor(hex: "#0a66ff"), + sidebarBackground: EditorColor(hex: "#f6f6f6"), + rowHover: EditorColor(hex: "#e8e8e8"), + rowSelected: EditorColor(hex: "#0a66ff24"), + barBackground: EditorColor(hex: "#f3f3f3"), + tabActiveBackground: EditorColor(hex: "#ffffff"), + tabHoverBackground: EditorColor(hex: "#eaeaea"), + panelBackground: EditorColor(hex: "#fafafa"), + overlayBackground: EditorColor(hex: "#ffffff"), + border: EditorColor(hex: "#d4d4d4"), + usesMaterials: true + ), + semantic: Semantic( + danger: EditorColor(hex: "#d7263d"), + success: EditorColor(hex: "#1e8e3e"), + warning: EditorColor(hex: "#b26a00") + ) ) - /// Maximum contrast, for accessibility. + /// Maximum contrast, for accessibility. Materials are off: translucency + /// spends contrast, which is the one thing this palette cannot spare. public static let highContrast = Theme( background: EditorColor(hex: "#000000"), text: EditorColor(hex: "#ffffff"), @@ -163,8 +163,6 @@ public struct Theme: Sendable, Equatable { currentLine: EditorColor(hex: "#1a1a1a"), selection: EditorColor(hex: "#0066cc"), cursor: EditorColor(hex: "#ffffff"), - statusBarBackground: EditorColor(hex: "#000000"), - separator: EditorColor(hex: "#ffffff"), keyword: EditorColor(hex: "#ffcc66"), string: EditorColor(hex: "#99cc99"), comment: EditorColor(hex: "#99cc99"), @@ -173,64 +171,30 @@ public struct Theme: Sendable, Equatable { variable: EditorColor(hex: "#ffffff"), function: EditorColor(hex: "#ff99cc"), property: EditorColor(hex: "#ffffff"), - colorScheme: .dark + colorScheme: .dark, + chrome: Chrome( + accent: EditorColor(hex: "#ffcc66"), + sidebarBackground: EditorColor(hex: "#000000"), + rowHover: EditorColor(hex: "#1a1a1a"), + rowSelected: EditorColor(hex: "#333333"), + barBackground: EditorColor(hex: "#000000"), + tabActiveBackground: EditorColor(hex: "#000000"), + tabHoverBackground: EditorColor(hex: "#222222"), + panelBackground: EditorColor(hex: "#000000"), + overlayBackground: EditorColor(hex: "#0a0a0a"), + border: EditorColor(hex: "#ffffff"), + usesMaterials: false + ), + semantic: Semantic( + danger: EditorColor(hex: "#ff6b6b"), + success: EditorColor(hex: "#5bd675"), + warning: EditorColor(hex: "#ffff00") + ) ) public static let all: [Theme] = [.dark, .light, .highContrast] } -// MARK: - ThemeManager - -/// Holds the active theme and persists the choice. -@MainActor -@Observable -public final class ThemeManager { - public private(set) var currentTheme: Theme - - private static let storageKey = "CodeEditor.selectedTheme" - - public init(theme: Theme = .dark) { - self.currentTheme = theme - } - - /// Restore the persisted theme, if any. - public func loadPersistedTheme(defaults: UserDefaults = .standard) { - guard let name = defaults.string(forKey: Self.storageKey) else { return } - if let theme = theme(named: name) { - currentTheme = theme - } - } - - public func setTheme(_ theme: Theme, defaults: UserDefaults = .standard) { - currentTheme = theme - defaults.set(theme.name, forKey: Self.storageKey) - } - - public func setTheme(named name: String, defaults: UserDefaults = .standard) { - guard let theme = theme(named: name) else { return } - setTheme(theme, defaults: defaults) - } - - public func toggleDarkLight(defaults: UserDefaults = .standard) { - setTheme(currentTheme == .dark ? .light : .dark, defaults: defaults) - } - - public func theme(named name: String) -> Theme? { - Theme.all.first { $0.name.caseInsensitiveCompare(name) == .orderedSame } - } - - /// Whether the palette forces a dark system appearance. - /// - /// Reads the declared `colorScheme`. The previous implementation compared the - /// background's red and blue channels, which returns `false` for any neutral - /// background — `#1e1e1e` is `30 < 30` — so the Dark and High Contrast palettes - /// rendered all system chrome in light mode. Do not derive this from colour - /// arithmetic again. - public var isDark: Bool { - currentTheme.colorScheme == .dark - } -} - extension Theme { /// Stable identifier used for persistence. public var name: String { diff --git a/Sources/CodeEditorThemes/Sources/ThemeManager.swift b/Sources/CodeEditorThemes/Sources/ThemeManager.swift new file mode 100644 index 0000000..9f3d4e2 --- /dev/null +++ b/Sources/CodeEditorThemes/Sources/ThemeManager.swift @@ -0,0 +1,53 @@ +import SwiftUI + +// MARK: - ThemeManager + +/// Holds the active theme and persists the choice. +@MainActor +@Observable +public final class ThemeManager { + public private(set) var currentTheme: Theme + + private static let storageKey = "CodeEditor.selectedTheme" + + public init(theme: Theme = .dark) { + self.currentTheme = theme + } + + /// Restore the persisted theme, if any. + public func loadPersistedTheme(defaults: UserDefaults = .standard) { + guard let name = defaults.string(forKey: Self.storageKey) else { return } + if let theme = theme(named: name) { + currentTheme = theme + } + } + + public func setTheme(_ theme: Theme, defaults: UserDefaults = .standard) { + currentTheme = theme + defaults.set(theme.name, forKey: Self.storageKey) + } + + public func setTheme(named name: String, defaults: UserDefaults = .standard) { + guard let theme = theme(named: name) else { return } + setTheme(theme, defaults: defaults) + } + + public func toggleDarkLight(defaults: UserDefaults = .standard) { + setTheme(currentTheme == .dark ? .light : .dark, defaults: defaults) + } + + public func theme(named name: String) -> Theme? { + Theme.all.first { $0.name.caseInsensitiveCompare(name) == .orderedSame } + } + + /// Whether the palette forces a dark system appearance. + /// + /// Reads the declared `colorScheme`. The previous implementation compared the + /// background's red and blue channels, which returns `false` for any neutral + /// background — `#1e1e1e` is `30 < 30` — so the Dark and High Contrast palettes + /// rendered all system chrome in light mode. Do not derive this from colour + /// arithmetic again. + public var isDark: Bool { + currentTheme.colorScheme == .dark + } +} diff --git a/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift b/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift index 8ddf9fd..6fc547c 100644 --- a/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift +++ b/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift @@ -27,7 +27,7 @@ public struct FileExplorerView: View { public var body: some View { VStack(spacing: 0) { header - Divider().overlay(theme.separator.color) + Divider().overlay(theme.chrome.border.color) Group { if let root = fileSystemManager.rootURL { @@ -152,7 +152,7 @@ public struct FileExplorerView: View { Image(systemName: item.iconName) .font(.system(size: 11)) - .foregroundColor(item.isDirectory ? theme.keyword.color : theme.secondaryText.color) + .foregroundColor(item.isDirectory ? theme.chrome.accent.color : theme.secondaryText.color) .frame(width: 14) Text(item.name) diff --git a/Sources/CodeEditorUI/Sources/Terminal/TerminalView.swift b/Sources/CodeEditorUI/Sources/Terminal/TerminalView.swift index fab64f2..0ba5c8a 100644 --- a/Sources/CodeEditorUI/Sources/Terminal/TerminalView.swift +++ b/Sources/CodeEditorUI/Sources/Terminal/TerminalView.swift @@ -18,7 +18,7 @@ public struct TerminalView: View { public var body: some View { VStack(spacing: 0) { header - Divider().overlay(theme.separator.color) + Divider().overlay(theme.chrome.border.color) ScrollViewReader { proxy in ScrollView { @@ -47,7 +47,7 @@ public struct TerminalView: View { .onTapGesture { isInputFocused = true } } - Divider().overlay(theme.separator.color) + Divider().overlay(theme.chrome.border.color) } .frame(minHeight: 120, idealHeight: 200) .background(theme.background.color) @@ -65,7 +65,7 @@ public struct TerminalView: View { .foregroundColor(theme.text.color) Circle() - .fill(terminal.isConnected ? Color.green : Color.red) + .fill(terminal.isConnected ? theme.semantic.success.color : theme.semantic.danger.color) .frame(width: 6, height: 6) if let error = terminal.connectionError { @@ -94,7 +94,7 @@ public struct TerminalView: View { private var inputLine: some View { HStack(spacing: 0) { Text("$ ") - .foregroundColor(theme.keyword.color) + .foregroundColor(theme.chrome.accent.color) TextField("", text: $input) .textFieldStyle(.plain) .foregroundColor(theme.text.color) @@ -118,7 +118,7 @@ public struct TerminalView: View { private func color(for type: TerminalOutputType) -> Color { switch type { case .standard: theme.text.color - case .errorOutput: theme.keyword.color + case .errorOutput: theme.semantic.danger.color case .info: theme.comment.color case .input: theme.string.color } From 7622593bb060aa8b0f35323079f53e2434c865db Mon Sep 17 00:00:00 2001 From: yan Date: Fri, 9 Oct 2026 20:24:46 +0300 Subject: [PATCH 04/22] feat(core): one spacing grid, one radius scale, one set of chrome heights The chrome previously used paddings of 2, 3, 4, 5, 6, 8 and 10 in seven combinations, fixed heights of 28 and 20, and zero corner radii anywhere. Metrics centralises the replacements: a 4pt spacing scale, radii keyed by surface, and the chrome heights. The grid itself is enforced by test. --- .../Sources/Design/Metrics.swift | 68 +++++++++++++++++++ Tests/CodeEditorTests/MetricsTests.swift | 45 ++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 Sources/CodeEditorCore/Sources/Design/Metrics.swift create mode 100644 Tests/CodeEditorTests/MetricsTests.swift diff --git a/Sources/CodeEditorCore/Sources/Design/Metrics.swift b/Sources/CodeEditorCore/Sources/Design/Metrics.swift new file mode 100644 index 0000000..97c8d35 --- /dev/null +++ b/Sources/CodeEditorCore/Sources/Design/Metrics.swift @@ -0,0 +1,68 @@ +import Foundation + +/// Layout constants for the window chrome. +/// +/// One grid. Spacing is a multiple of 4; radii grow with the surface they round. +/// Views must not introduce padding values that are not on this scale — the +/// original chrome used 2, 3, 5, 6 and 10 in seven combinations, which is most of +/// why it read as a draft. +/// +/// Lives in `CodeEditorCore` because it is pure layout arithmetic, testable +/// without a window server, like everything else here. +public enum Metrics { + /// Horizontal and vertical rhythm. All values are multiples of 4. + public enum Space { + /// Tight internal padding: icon-to-label, chip insets. + public static let compact: CGFloat = 4 + + /// Standard padding inside rows, buttons and bar items. + public static let regular: CGFloat = 8 + + /// Padding for headers, panel bodies and grouped content. + public static let relaxed: CGFloat = 12 + + /// Window-edge and section padding. + public static let loose: CGFloat = 16 + + /// Generous spacing for empty states and overlays. + public static let spacious: CGFloat = 24 + } + + /// Corner radii, keyed by the kind of surface being rounded. + /// Use `ContinuousCornerShape`-style smoothing (`.continuous`) with these. + public enum Radius { + /// List rows, tree rows, tab shapes. + public static let row: CGFloat = 5 + + /// Buttons, text fields, menu chips. + public static let control: CGFloat = 6 + + /// Docked panels and floating overlays. + public static let panel: CGFloat = 8 + } + + /// Fixed chrome heights. Anything resizeable stores its size in preferences + /// instead. + public enum Height { + /// The tab strip. Xcode is 32; 28 was cramped at the semantic font sizes. + public static let tabStrip: CGFloat = 32 + + /// The status bar. + public static let statusBar: CGFloat = 22 + + /// The terminal panel header row. + public static let panelHeader: CGFloat = 24 + + /// The drag handle between the editor and the terminal panel. + public static let divider: CGFloat = 6 + + /// The visual indicator inside the divider hit area. + public static let dividerIndicator: CGFloat = 3 + } + + /// Indentation for each level of tree depth in the sidebar. + public static let treeIndentPerLevel: CGFloat = 12 + + /// The smallest interactive target the chrome offers. + public static let minimumHitTarget: CGFloat = 24 +} diff --git a/Tests/CodeEditorTests/MetricsTests.swift b/Tests/CodeEditorTests/MetricsTests.swift new file mode 100644 index 0000000..2b5c2dc --- /dev/null +++ b/Tests/CodeEditorTests/MetricsTests.swift @@ -0,0 +1,45 @@ +import CodeEditorCore +import Testing + +@Suite("Metrics") +struct MetricsTests { + @Test("Every spacing step sits on the 4-point grid") + func spacingIsOnGrid() { + let steps = [ + Metrics.Space.compact, + Metrics.Space.regular, + Metrics.Space.relaxed, + Metrics.Space.loose, + Metrics.Space.spacious, + ] + for step in steps { + #expect(step.truncatingRemainder(dividingBy: 4) == 0, "spacing \(step) is off the 4pt grid") + } + } + + @Test("Spacing steps are strictly increasing") + func spacingAscends() { + #expect(Metrics.Space.compact < Metrics.Space.regular) + #expect(Metrics.Space.regular < Metrics.Space.relaxed) + #expect(Metrics.Space.relaxed < Metrics.Space.loose) + #expect(Metrics.Space.loose < Metrics.Space.spacious) + } + + @Test("Radii grow with the surface they round") + func radiiAscendBySurface() { + #expect(Metrics.Radius.row < Metrics.Radius.control) + #expect(Metrics.Radius.control < Metrics.Radius.panel) + } + + @Test("Chrome heights clear the smallest hit target") + func chromeHeightsFitContent() { + #expect(Metrics.Height.tabStrip >= Metrics.minimumHitTarget) + #expect(Metrics.Height.statusBar >= Metrics.minimumHitTarget - 2) + #expect(Metrics.Height.dividerIndicator < Metrics.Height.divider) + } + + @Test("Tree indentation sits on the grid") + func treeIndentIsOnGrid() { + #expect(Metrics.treeIndentPerLevel.truncatingRemainder(dividingBy: 4) == 0) + } +} From 6564838579eda8bd01835eed5c06850794272968 Mon Sep 17 00:00:00 2001 From: yan Date: Fri, 9 Oct 2026 20:25:20 +0300 Subject: [PATCH 05/22] feat(themes): a semantic type ramp for the chrome Thirteen hardcoded .system(size:) calls in the chrome are replaced by named roles over semantic styles, so sidebar, tabs, bars and overlays scale with the user's text size. The code font stays fixed-size and owned by EditorState; code reflowing under Dynamic Type mid-edit is worse than the accessibility it would buy. --- .../CodeEditorThemes/Sources/Typography.swift | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 Sources/CodeEditorThemes/Sources/Typography.swift diff --git a/Sources/CodeEditorThemes/Sources/Typography.swift b/Sources/CodeEditorThemes/Sources/Typography.swift new file mode 100644 index 0000000..2c8979d --- /dev/null +++ b/Sources/CodeEditorThemes/Sources/Typography.swift @@ -0,0 +1,39 @@ +import SwiftUI + +// MARK: - Typography + +/// The chrome type ramp. Semantic styles only, so chrome text scales with the +/// user's system text size the way it does in Xcode. +/// +/// The code canvas is deliberately absent: the editor font is a fixed-size SF Mono +/// owned by `EditorState` and persisted as a preference, because code reflowing +/// under Dynamic Type is disruptive mid-edit. +/// +/// Rule: chrome views use these roles. `.system(size:)` is banned outside the +/// canvas — the original chrome hardcoded 11pt and 12pt in thirteen places, which +/// neither scaled nor stayed consistent. +public enum Typography { + /// Sidebar tree rows and file names. `.body`, 13pt — the Xcode navigator size. + public static let sidebarRow: Font = .body + + /// Tab strip labels. + public static let tabLabel: Font = .subheadline + + /// Sidebar section headers such as the project name. Never all-caps. + public static let sectionHeader: Font = .caption.weight(.semibold) + + /// Status bar readouts. + public static let statusBar: Font = .caption + + /// Panel headers, such as the terminal's title row. + public static let panelHeader: Font = .caption.weight(.semibold) + + /// The empty-state headline. + public static let emptyTitle: Font = .title2.weight(.semibold) + + /// The empty-state supporting line. + public static let emptyBody: Font = .callout + + /// Keyboard-hint chips in overlays. + public static let hint: Font = .caption2.monospaced() +} From b8bf354265024b1fb1ba3fbcdff5d272157e43b1 Mon Sep 17 00:00:00 2001 From: yan Date: Fri, 9 Oct 2026 20:27:03 +0300 Subject: [PATCH 06/22] feat(core): reveal a file's ancestors and clear the manager error reveal(url:) expands every directory between the root and a file so the sidebar can show the document that just opened, without re-reading ancestors that are already expanded. clearError() exists because the sidebar's alert binding only cleared the view's own error copy, so dismissing it left the manager's error set and the alert re-presented immediately. --- .../FileSystem/FileSystemManager.swift | 28 +++++++++ Tests/CodeEditorTests/CodeEditorTests.swift | 61 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/Sources/CodeEditorCore/Sources/FileSystem/FileSystemManager.swift b/Sources/CodeEditorCore/Sources/FileSystem/FileSystemManager.swift index 81bc519..c603f5a 100644 --- a/Sources/CodeEditorCore/Sources/FileSystem/FileSystemManager.swift +++ b/Sources/CodeEditorCore/Sources/FileSystem/FileSystemManager.swift @@ -285,6 +285,34 @@ public final class FileSystemManager { } } + /// Expand every directory between the root and `url`, so `url` is visible in + /// the tree. A no-op for paths outside the root or for the root itself. + /// + /// Ancestors already expanded are not re-read. The final segment is not + /// expanded — it is the file being revealed. + public func reveal(_ url: URL) async { + guard let rootURL else { return } + let rootKey = Self.pathKey(for: rootURL) + let targetKey = Self.pathKey(for: url) + guard targetKey.hasPrefix(rootKey + "/") else { return } + + var current = rootKey + let segments = targetKey.dropFirst(rootKey.count + 1).split(separator: "/").map(String.init) + for segment in segments.dropLast() { + current += "/" + segment + if expandedPaths.insert(current).inserted { + await load(URL(fileURLWithPath: current, isDirectory: true)) + } + } + } + + /// Dismiss the current error. The view layer calls this when the user closes + /// the error alert; without it the presenting condition stays true and the + /// alert re-presents immediately. + public func clearError() { + errorMessage = nil + } + // MARK: - Mutations public func createFile(named name: String, in directory: URL) async throws { diff --git a/Tests/CodeEditorTests/CodeEditorTests.swift b/Tests/CodeEditorTests/CodeEditorTests.swift index 711a1c4..f6f7bc0 100644 --- a/Tests/CodeEditorTests/CodeEditorTests.swift +++ b/Tests/CodeEditorTests/CodeEditorTests.swift @@ -427,6 +427,67 @@ struct FileSystemManagerTests { let withSlash = URL(fileURLWithPath: "/tmp/stub/") #expect(manager.children(of: withSlash).map(\.name) == ["a.txt"]) } + + @Test("reveal expands every ancestor of a deeply nested file") + func revealExpandsAncestors() async { + let root = URL(fileURLWithPath: "/tmp/stub") + let sub = root.appendingPathComponent("Sources") + let nested = sub.appendingPathComponent("App") + let file = nested.appendingPathComponent("Main.swift") + let provider = StubProvider(directories: [root, sub, nested], files: [file: "x"]) + let manager = FileSystemManager(provider: provider) + + manager.setRoot(root) + await manager.load(root) + #expect(!manager.isExpanded(sub)) + + await manager.reveal(file) + + #expect(manager.isExpanded(sub)) + #expect(manager.isExpanded(nested)) + // The file's own directory is loaded, the file itself is not a directory to expand. + #expect(!manager.children(of: nested).isEmpty) + } + + @Test("reveal is a no-op outside the root") + func revealOutsideRoot() async { + let root = URL(fileURLWithPath: "/tmp/stub") + let provider = StubProvider(directories: [root]) + let manager = FileSystemManager(provider: provider) + manager.setRoot(root) + await manager.load(root) + + await manager.reveal(URL(fileURLWithPath: "/elsewhere/thing.swift")) + #expect(manager.children(of: root).isEmpty) + } + + @Test("reveal does not reload already-expanded ancestors") + func revealSkipsExpanded() async throws { + let root = URL(fileURLWithPath: "/tmp/stub") + let sub = root.appendingPathComponent("dir") + let file = sub.appendingPathComponent("a.txt") + let provider = StubProvider(directories: [root, sub], files: [file: "a"]) + let manager = FileSystemManager(provider: provider) + manager.setRoot(root) + await manager.load(root) + + manager.toggleExpansion(of: try #require(manager.children(of: root).first)) + await manager.load(sub) + await manager.reveal(file) + #expect(manager.isExpanded(sub)) + } + + @Test("clearError dismisses the error so an alert can close") + func clearErrorDismisses() { + let root = URL(fileURLWithPath: "/tmp/stub") + let provider = StubProvider(files: [root: "x"]) + let manager = FileSystemManager(provider: provider) + manager.setRoot(root) + #expect(manager.errorMessage != nil) + + manager.clearError() + #expect(manager.errorMessage == nil) + } } // MARK: - FileSystemItem From f1e7dcaef617c0bcd14ac38c0a65bb54b4478873 Mon Sep 17 00:00:00 2001 From: yan Date: Fri, 9 Oct 2026 20:29:43 +0300 Subject: [PATCH 07/22] feat(ui): rewrite the sidebar as a native navigator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selection drives behaviour: clicking a file opens it and clicking a folder selects it, the Xcode navigator model, replacing the reversed scheme where a single click set an invisible create-target and only a double-click expanded a folder. The disclosure triangle is the only thing that toggles expansion. The create-target concept no longer has any UI. The hand-rolled header — a bold 12pt label that truncated 'No Folder Opened' to 'No Fold...Opened' next to two unstyled buttons — is gone; the toolbar owns the project identity now. The sidebar stops overriding the system material with scrollContentBackground(.hidden) plus a flat fill, so .sidebar renders its own vibrancy. The active document is revealed by expanding its ancestors, shown bold with an accent icon, and drives List selection. Delete gains a confirmation dialog. Error alerts clear both error copies, fixing the re-present loop. Sheets use grouped forms on the metrics grid. --- .../Sources/MainWindowView.swift | 17 +- Sources/CodeEditorUI/AppState.swift | 40 ++ .../Sources/Sidebar/FileExplorerView.swift | 396 +++++++----------- .../Sources/Sidebar/ItemSheets.swift | 149 +++++++ 4 files changed, 347 insertions(+), 255 deletions(-) create mode 100644 Sources/CodeEditorUI/Sources/Sidebar/ItemSheets.swift diff --git a/Sources/CodeEditorApp/Sources/MainWindowView.swift b/Sources/CodeEditorApp/Sources/MainWindowView.swift index c599d99..2796d85 100644 --- a/Sources/CodeEditorApp/Sources/MainWindowView.swift +++ b/Sources/CodeEditorApp/Sources/MainWindowView.swift @@ -15,7 +15,11 @@ struct MainWindowView: View { FileExplorerView( fileSystemManager: appState.fileSystemManager, theme: appState.theme, - onOpenFile: openFile + activeDocumentURL: appState.documentManager.activeDocument?.url, + onOpenFile: openFile, + onCreateIn: { directory, kind in + appState.requestCreation(kind: kind, in: directory) + } ) .navigationSplitViewColumnWidth(min: 180, ideal: 240, max: 420) } detail: { @@ -38,7 +42,12 @@ struct MainWindowView: View { } .background(appState.theme.background.color) .tint(appState.theme.chrome.accent.color) - .preferredColorScheme(appState.themeManager.isDark ? .dark : .light) + .preferredColorScheme(appState.theme.colorScheme) + .sheet(item: $appState.pendingCreation) { pending in + NewItemSheetView(pending: pending) { name in + appState.performCreation(named: name) + } + } .alert("Code Editor", isPresented: errorBinding) { Button("OK", role: .cancel) { appState.errorMessage = nil } } message: { @@ -77,8 +86,8 @@ struct MainWindowView: View { ) } - private func openFile(_ url: URL) { - appState.openFile(url) + private var openFile: (URL) -> Void { + { url in appState.openFile(url) } } } diff --git a/Sources/CodeEditorUI/AppState.swift b/Sources/CodeEditorUI/AppState.swift index 34a0fc9..b13d908 100644 --- a/Sources/CodeEditorUI/AppState.swift +++ b/Sources/CodeEditorUI/AppState.swift @@ -20,6 +20,10 @@ public final class AppState { public var errorMessage: String? + /// A creation the user has requested but not yet named. Presented as one sheet + /// regardless of whether the toolbar menu or a context menu requested it. + public var pendingCreation: PendingCreation? + public init() { let fileSystemManager = FileSystemManager() let themeManager = ThemeManager() @@ -163,6 +167,42 @@ public final class AppState { // MARK: - Commands + /// Where new items land when no directory is given: beside the active + /// document, or at the workspace root. The Xcode rule. + public var defaultCreateDirectory: URL? { + if let parent = documentManager.activeDocument?.url?.deletingLastPathComponent() { + return parent + } + return fileSystemManager.rootURL + } + + /// Queue a creation for naming. `directory` nil means `defaultCreateDirectory`. + public func requestCreation(kind: NewItemKind, in directory: URL? = nil) { + guard let directory = directory ?? defaultCreateDirectory else { return } + pendingCreation = PendingCreation(kind: kind, directory: directory) + } + + /// Create the pending item and open it if it is a file. + public func performCreation(named name: String) { + guard let pending = pendingCreation else { return } + let trimmed = name.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return } + + Task { + do { + switch pending.kind { + case .file: + try await fileSystemManager.createFile(named: trimmed, in: pending.directory) + openFile(pending.directory.appendingPathComponent(trimmed)) + case .folder: + try await fileSystemManager.createFolder(named: trimmed, in: pending.directory) + } + } catch { + errorMessage = error.localizedDescription + } + } + } + public func toggleSidebar() { isSidebarVisible.toggle() } diff --git a/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift b/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift index 6fc547c..5cd9a7b 100644 --- a/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift +++ b/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift @@ -3,251 +3,245 @@ import CodeEditorThemes import SwiftUI /// Recursive file tree for the sidebar. +/// +/// Clicking a row selects it. Selecting a file opens it in the editor — the Xcode +/// navigator behaviour — and selecting a folder makes it the target of the toolbar's +/// create menu. The disclosure triangle (and only the triangle) toggles expansion, +/// which is what every Mac outline view does; the previous version required a +/// double-click and used a single click to set an invisible create-target. +/// +/// The active document is emphasised (bold name, accent icon) and revealed by +/// expanding its ancestors whenever it changes. public struct FileExplorerView: View { @Bindable var fileSystemManager: FileSystemManager let theme: Theme + let activeDocumentURL: URL? let onOpenFile: (URL) -> Void + let onCreateIn: (URL, NewItemKind) -> Void - /// Directory that "New File/Folder" targets; defaults to the root. - @State private var targetFolder: FileSystemItem? - @State private var sheet: NewItemSheet? + @State private var selection: FileSystemItem? @State private var renaming: FileSystemItem? + @State private var deleting: FileSystemItem? @State private var errorMessage: String? public init( fileSystemManager: FileSystemManager, theme: Theme, - onOpenFile: @escaping (URL) -> Void + activeDocumentURL: URL?, + onOpenFile: @escaping (URL) -> Void, + onCreateIn: @escaping (URL, NewItemKind) -> Void ) { self.fileSystemManager = fileSystemManager self.theme = theme + self.activeDocumentURL = activeDocumentURL self.onOpenFile = onOpenFile + self.onCreateIn = onCreateIn } public var body: some View { - VStack(spacing: 0) { - header - Divider().overlay(theme.chrome.border.color) - - Group { - if let root = fileSystemManager.rootURL { - list(for: root) - } else { - emptyState - } + Group { + if let root = fileSystemManager.rootURL { + list(for: root) + } else { + emptyState } } - .frame(minWidth: 180, idealWidth: 240, maxWidth: 420) - .background(theme.background.color) - .sheet(item: $sheet) { sheet in - NewItemSheetView(kind: sheet.kind) { name in - create(sheet.kind, named: name) - } + .onChange(of: selection) { _, newValue in + guard let item = newValue, !item.isDirectory else { return } + onOpenFile(item.url) + } + .task(id: activeDocumentURL) { + await revealActiveDocument() } .sheet(item: $renaming) { item in RenameSheetView(current: item.name) { newName in Task { await rename(item, to: newName) } } } + .confirmationDialog( + "Delete “\(deleting?.name ?? "")”?", + isPresented: deletingBinding, + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let item = deleting { Task { await delete(item) } } + } + } message: { + Text("The item is deleted immediately. This cannot be undone.") + } .alert("File Explorer", isPresented: errorBinding) { - Button("OK", role: .cancel) { errorMessage = nil } + Button("OK", role: .cancel) { + errorMessage = nil + fileSystemManager.clearError() + } } message: { - Text(errorMessage ?? "") + Text(errorMessage ?? fileSystemManager.errorMessage ?? "") } } // MARK: - Subviews - private var header: some View { - HStack(spacing: 4) { - Text(fileSystemManager.rootName) - .font(.caption.weight(.semibold)) - .foregroundColor(theme.text.color) - .lineLimit(1) - .truncationMode(.middle) - - Spacer(minLength: 0) - - Button { - sheet = NewItemSheet(kind: .file) - } label: { - Image(systemName: "doc.badge.plus") - } - .help("New File") - - Button { - sheet = NewItemSheet(kind: .folder) - } label: { - Image(systemName: "folder.badge.plus") - } - .help("New Folder") - } - .labelStyle(.iconOnly) - .font(.system(size: 12)) - .padding(.horizontal, 8) - .padding(.vertical, 6) - } - + /// One quiet line when no folder is open. The canvas owns the empty-state + /// experience — a second one here competed with it. private var emptyState: some View { - VStack(spacing: 8) { - Image(systemName: "folder") - .font(.title2) - .foregroundColor(theme.secondaryText.color) - Text("No folder open") - .font(.caption) - .foregroundColor(theme.secondaryText.color) - Button("Open Folder…") { - NotificationCenter.default.post(name: .codeEditorOpenFolder, object: nil) - } - .controlSize(.small) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) + Text("No folder open") + .font(Typography.emptyBody) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) } private func list(for root: URL) -> some View { - List(selection: selectionBinding) { - ForEach(rows(from: root)) { row in + List(selection: $selection) { + ForEach(flattenedRows(from: root)) { row in rowView(row.item) .tag(row.item) - .padding(.leading, CGFloat(row.depth) * 12) + .padding(.leading, CGFloat(row.depth) * Metrics.treeIndentPerLevel) } } .listStyle(.sidebar) - .scrollContentBackground(.hidden) - .background(theme.background.color) } - /// Flatten the expanded tree into indentable rows. - private func rows(from directory: URL, depth: Int = 0) -> [FileRow] { + /// Flatten the expanded tree into indentable rows. Only expanded branches are + /// enumerated, so a large workspace costs nothing until it is opened. + private func flattenedRows(from directory: URL, depth: Int = 0) -> [FileRow] { guard depth < 32 else { return [] } return fileSystemManager.children(of: directory).flatMap { item -> [FileRow] in guard item.isDirectory else { return [FileRow(item: item, depth: depth)] } - var result: [FileRow] = [FileRow(item: item, depth: depth)] + var result = [FileRow(item: item, depth: depth)] if fileSystemManager.isExpanded(item.url) { - result.append(contentsOf: rows(from: item.url, depth: depth + 1)) + result.append(contentsOf: flattenedRows(from: item.url, depth: depth + 1)) } return result } } private func rowView(_ item: FileSystemItem) -> some View { - let isTarget = targetFolder?.url == item.url + let isActiveDocument = item.url == activeDocumentURL - return HStack(spacing: 4) { - if item.isDirectory { - Button { - fileSystemManager.toggleExpansion(of: item) - } label: { - Image(systemName: fileSystemManager.isExpanded(item.url) ? "chevron.down" : "chevron.right") - .font(.system(size: 9, weight: .bold)) - .foregroundColor(theme.secondaryText.color) - } - .buttonStyle(.plain) - .frame(width: 12) - } else { - Color.clear.frame(width: 12) - } + return HStack(spacing: Metrics.Space.compact) { + disclosureControl(for: item) Image(systemName: item.iconName) - .font(.system(size: 11)) - .foregroundColor(item.isDirectory ? theme.chrome.accent.color : theme.secondaryText.color) - .frame(width: 14) + .font(Typography.sidebarRow) + .foregroundStyle(iconColor(for: item, isActiveDocument: isActiveDocument)) + .frame(width: 16) Text(item.name) - .font(.system(size: 12)) - .foregroundColor(theme.text.color) + .font(Typography.sidebarRow) + .fontWeight(isActiveDocument ? .semibold : .regular) .lineLimit(1) .truncationMode(.middle) - if isTarget { - Image(systemName: "target") - .font(.system(size: 9)) - .foregroundColor(theme.secondaryText.color) - .help("New items will be created here") - } - Spacer(minLength: 0) } .padding(.vertical, 2) .contentShape(Rectangle()) - .onTapGesture(count: 2) { - if item.isDirectory { - fileSystemManager.toggleExpansion(of: item) - } else { - onOpenFile(item.url) - } - } - .onTapGesture { - if item.isDirectory { - targetFolder = item - } - } .contextMenu { - Button("New File Here") { - targetFolder = item - sheet = NewItemSheet(kind: .file) - } - Button("New Folder Here") { - targetFolder = item - sheet = NewItemSheet(kind: .folder) - } - if !item.isDirectory { + if item.isDirectory { + Button { + onCreateIn(item.url, .file) + } label: { + Label("New File Here", systemImage: "doc.badge.plus") + } + Button { + onCreateIn(item.url, .folder) + } label: { + Label("New Folder Here", systemImage: "folder.badge.plus") + } Divider() - Button("Rename…") { renaming = item } + } + Button { + renaming = item + } label: { + Label("Rename…", systemImage: "pencil") } Divider() - Button("Reveal in Finder") { + Button { NSWorkspace.shared.activateFileViewerSelecting([item.url]) + } label: { + Label("Reveal in Finder", systemImage: "finder") } - Button("Copy Path") { + Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(item.url.path, forType: .string) + } label: { + Label("Copy Path", systemImage: "doc.on.doc") } Divider() - Button("Delete", role: .destructive) { - Task { await delete(item) } + Button(role: .destructive) { + deleting = item + } label: { + Label("Delete…", systemImage: "trash") } } } - // MARK: - Actions + /// The disclosure triangle for folders, a spacer of the same width for files, + /// so names align whether or not their parent expands. + @ViewBuilder + private func disclosureControl(for item: FileSystemItem) -> some View { + if item.isDirectory { + Button { + fileSystemManager.toggleExpansion(of: item) + } label: { + Image(systemName: fileSystemManager.isExpanded(item.url) ? "chevron.down" : "chevron.right") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.tertiary) + .frame(width: 12, height: 12) + } + .buttonStyle(.plain) + .help(fileSystemManager.isExpanded(item.url) ? "Collapse" : "Expand") + } else { + Color.clear.frame(width: 12, height: 12) + } + } + + private func iconColor(for item: FileSystemItem, isActiveDocument: Bool) -> Color { + if isActiveDocument { + return theme.chrome.accent.color + } + return item.isDirectory ? theme.chrome.accent.color.opacity(0.7) : .secondary + } + + // MARK: - Reveal and selection + + /// Reveal and emphasise the active document whenever it changes, including on + /// first appearance. + private func revealActiveDocument() async { + guard let url = activeDocumentURL else { return } + await fileSystemManager.reveal(url) + + let parent = url.deletingLastPathComponent() + selection = fileSystemManager.children(of: parent).first { $0.url == url } ?? selection + } - private var selectionBinding: Binding { + // MARK: - Bindings + + private var deletingBinding: Binding { Binding( - get: { targetFolder }, - set: { targetFolder = $0 } + get: { deleting != nil }, + set: { if !$0 { deleting = nil } } ) } + /// Dismissing the alert must clear both error copies. Clearing only the view's + /// own copy left the manager's set, so the alert re-presented immediately. private var errorBinding: Binding { Binding( get: { errorMessage != nil || fileSystemManager.errorMessage != nil }, - set: { if !$0 { errorMessage = nil } } - ) - } - - private func create(_ kind: NewItemKind, named rawName: String) { - let name = rawName.trimmingCharacters(in: .whitespaces) - let directory = targetFolder?.url ?? fileSystemManager.rootURL - guard !name.isEmpty, let directory else { return } - - Task { - do { - switch kind { - case .file: - try await fileSystemManager.createFile(named: name, in: directory) - onOpenFile(directory.appendingPathComponent(name)) - case .folder: - try await fileSystemManager.createFolder(named: name, in: directory) + set: { newValue in + if !newValue { + errorMessage = nil + fileSystemManager.clearError() } - } catch { - errorMessage = error.localizedDescription } - } + ) } + // MARK: - Actions + private func rename(_ item: FileSystemItem, to newName: String) async { let name = newName.trimmingCharacters(in: .whitespaces) guard !name.isEmpty, name != item.name else { return } @@ -277,106 +271,6 @@ private struct FileRow: Identifiable { var id: URL { item.url } } -// MARK: - Sheets - -private enum NewItemKind { - case file - case folder - - var title: String { - switch self { - case .file: return "New File" - case .folder: return "New Folder" - } - } - - var placeholder: String { - switch self { - case .file: return "File name" - case .folder: return "Folder name" - } - } -} - -private struct NewItemSheet: Identifiable { - let id = UUID() - var kind: NewItemKind -} - -private struct NewItemSheetView: View { - let kind: NewItemKind - let onCreate: (String) -> Void - - @Environment(\.dismiss) private var dismiss - @State private var name = "" - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text(kind.title).font(.headline) - - TextField(kind.placeholder, text: $name) - .textFieldStyle(.roundedBorder) - .onSubmit(submit) - - HStack { - Spacer() - Button("Cancel", role: .cancel) { dismiss() } - Button("Create", action: submit) - .keyboardShortcut(.defaultAction) - .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) - } - } - .padding(20) - .frame(width: 320) - } - - private func submit() { - let trimmed = name.trimmingCharacters(in: .whitespaces) - guard !trimmed.isEmpty else { return } - onCreate(trimmed) - dismiss() - } -} - -private struct RenameSheetView: View { - let current: String - let onRename: (String) -> Void - - @Environment(\.dismiss) private var dismiss - @State private var name: String - - init(current: String, onRename: @escaping (String) -> Void) { - self.current = current - self.onRename = onRename - _name = State(initialValue: current) - } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Rename “\(current)”").font(.headline) - - TextField("New name", text: $name) - .textFieldStyle(.roundedBorder) - .onSubmit(submit) - - HStack { - Spacer() - Button("Cancel", role: .cancel) { dismiss() } - Button("Rename", action: submit) - .keyboardShortcut(.defaultAction) - .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || name == current) - } - } - .padding(20) - .frame(width: 320) - } - - private func submit() { - onRename(name) - dismiss() - } -} - // MARK: - Notifications extension Notification.Name { diff --git a/Sources/CodeEditorUI/Sources/Sidebar/ItemSheets.swift b/Sources/CodeEditorUI/Sources/Sidebar/ItemSheets.swift new file mode 100644 index 0000000..cfd1d5a --- /dev/null +++ b/Sources/CodeEditorUI/Sources/Sidebar/ItemSheets.swift @@ -0,0 +1,149 @@ +import CodeEditorCore +import CodeEditorThemes +import SwiftUI + +// MARK: - New item kinds + +/// What the create flows can add to a directory. +public enum NewItemKind: String { + case file + case folder + + var title: String { + switch self { + case .file: "New File" + case .folder: "New Folder" + } + } + + var placeholder: String { + switch self { + case .file: "File name" + case .folder: "Folder name" + } + } + + var iconName: String { + switch self { + case .file: "doc.badge.plus" + case .folder: "folder.badge.plus" + } + } +} + +/// A creation the user has requested but not yet named. +/// +/// Owned by `AppState` so the toolbar's create menu and the sidebar's context menu +/// funnel into one sheet presentation. +public struct PendingCreation: Identifiable { + public let id = UUID() + public let kind: NewItemKind + public let directory: URL + + public init(kind: NewItemKind, directory: URL) { + self.kind = kind + self.directory = directory + } +} + +// MARK: - Sheets + +/// Sheet for naming a new file or folder. Grouped form, default action on Return. +public struct NewItemSheetView: View { + let pending: PendingCreation + let onCreate: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var name = "" + + public init(pending: PendingCreation, onCreate: @escaping (String) -> Void) { + self.pending = pending + self.onCreate = onCreate + } + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(pending.kind.title) + .font(.headline) + .padding(.bottom, Metrics.Space.relaxed) + + Form { + TextField(pending.kind.placeholder, text: $name) + .onSubmit(submit) + } + .formStyle(.grouped) + + HStack { + Text(pending.directory.path) + .font(Typography.hint) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .help(pending.directory.path) + + Spacer() + + Button("Cancel", role: .cancel) { dismiss() } + Button("Create", action: submit) + .keyboardShortcut(.defaultAction) + .disabled(trimmed.isEmpty) + } + .padding(.top, Metrics.Space.regular) + } + .padding(Metrics.Space.loose) + .frame(width: 380) + } + + private var trimmed: String { name.trimmingCharacters(in: .whitespaces) } + + private func submit() { + guard !trimmed.isEmpty else { return } + onCreate(trimmed) + dismiss() + } +} + +/// Sheet for renaming an existing item. +struct RenameSheetView: View { + let current: String + let onRename: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var name: String + + init(current: String, onRename: @escaping (String) -> Void) { + self.current = current + self.onRename = onRename + _name = State(initialValue: current) + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text("Rename “\(current)”") + .font(.headline) + .padding(.bottom, Metrics.Space.relaxed) + + Form { + TextField("New name", text: $name) + .onSubmit(submit) + } + .formStyle(.grouped) + + HStack { + Spacer() + Button("Cancel", role: .cancel) { dismiss() } + Button("Rename", action: submit) + .keyboardShortcut(.defaultAction) + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || name == current) + } + .padding(.top, Metrics.Space.regular) + } + .padding(Metrics.Space.loose) + .frame(width: 380) + } + + private func submit() { + onRename(name) + dismiss() + } +} From 13891da216422f1c618642e6b59e472a881ee69e Mon Sep 17 00:00:00 2001 From: yan Date: Fri, 9 Oct 2026 20:51:06 +0300 Subject: [PATCH 08/22] feat(ui): unified toolbar, merged tabs, honest status bar, one empty state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window gains a real toolbar: breadcrumb in the principal slot, create menu and terminal toggle trailing. The sidebar's hand-rolled header is gone. One create menu replaces two icon buttons; new items land beside the active document, the Xcode rule. The tab strip moves from 28pt of hardcoded sizes to the metrics grid: 32pt, active tab filled with the canvas colour and rounded so it reads as the top of the editor, hover wash on inactive tabs, dirty dot beside the title, and a close button that is always present — swapping × for a dot had made modified tabs impossible to close without saving. The status bar drops the byte count and gains encoding, line ending and language, grouped with hairline separators. One empty state, in the canvas, adapting to what is missing: with a workspace it points at the sidebar; without one it offers Open Folder as the single prominent action. Previously both panes carried their own empty message in different capitalisations with duplicate buttons. Closing a modified document now routes through a SwiftUI confirmation dialog instead of NSAlert.runModal(), which blocked the main thread and could not share styling with the rest of the chrome. --- .../Sources/MainWindowView.swift | 242 +++++++++--------- Sources/CodeEditorUI/AppState.swift | 63 +++-- .../Sources/Chrome/EmptyStateView.swift | 87 +++++++ .../Sources/Chrome/StatusBarView.swift | 71 +++++ .../Sources/Chrome/TabBarView.swift | 174 +++++++++++++ 5 files changed, 486 insertions(+), 151 deletions(-) create mode 100644 Sources/CodeEditorUI/Sources/Chrome/EmptyStateView.swift create mode 100644 Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift create mode 100644 Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift diff --git a/Sources/CodeEditorApp/Sources/MainWindowView.swift b/Sources/CodeEditorApp/Sources/MainWindowView.swift index 2796d85..78abd4a 100644 --- a/Sources/CodeEditorApp/Sources/MainWindowView.swift +++ b/Sources/CodeEditorApp/Sources/MainWindowView.swift @@ -1,4 +1,5 @@ import CodeEditorCore +import CodeEditorThemes import CodeEditorUI import SwiftUI @@ -16,7 +17,7 @@ struct MainWindowView: View { fileSystemManager: appState.fileSystemManager, theme: appState.theme, activeDocumentURL: appState.documentManager.activeDocument?.url, - onOpenFile: openFile, + onOpenFile: { url in appState.openFile(url) }, onCreateIn: { directory, kind in appState.requestCreation(kind: kind, in: directory) } @@ -24,12 +25,19 @@ struct MainWindowView: View { .navigationSplitViewColumnWidth(min: 180, ideal: 240, max: 420) } detail: { VStack(spacing: 0) { - TabBar(documents: appState.documentManager.documents, appState: appState) + TabBarView( + documents: appState.documentManager.documents, + activeDocument: appState.documentManager.activeDocument, + theme: appState.theme, + onSelect: { document in appState.documentManager.activate(document) }, + onClose: { document in appState.requestClose(document) }, + onNew: { appState.newFile() } + ) editorArea if appState.isStatusBarVisible { - StatusBar(appState: appState) + StatusBarView(appState: appState) } if appState.isTerminalVisible { @@ -40,6 +48,8 @@ struct MainWindowView: View { } .background(appState.theme.background.color) } + .navigationTitle(navigationTitle) + .toolbar { toolbarContent } .background(appState.theme.background.color) .tint(appState.theme.chrome.accent.color) .preferredColorScheme(appState.theme.colorScheme) @@ -48,6 +58,23 @@ struct MainWindowView: View { appState.performCreation(named: name) } } + .confirmationDialog( + "Close “\(appState.pendingClose?.name ?? "")”?", + isPresented: pendingCloseBinding, + titleVisibility: .visible + ) { + Button("Save & Close") { + Task { await appState.resolvePendingClose(save: true) } + } + Button("Discard Changes", role: .destructive) { + Task { await appState.resolvePendingClose(save: false) } + } + Button("Cancel", role: .cancel) { + appState.pendingClose = nil + } + } message: { + Text("Unsaved changes will be lost if you discard them.") + } .alert("Code Editor", isPresented: errorBinding) { Button("OK", role: .cancel) { appState.errorMessage = nil } } message: { @@ -55,7 +82,7 @@ struct MainWindowView: View { } } - // MARK: - Subviews + // MARK: - Content @ViewBuilder private var editorArea: some View { @@ -67,159 +94,118 @@ struct MainWindowView: View { ) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - WelcomeView(appState: appState) - .frame(maxWidth: .infinity, maxHeight: .infinity) + EmptyStateView( + appState: appState, + onOpenFolder: { appState.openFolder() }, + onNewFile: { appState.newFile() } + ) } } - private var columnVisibility: Binding { - Binding( - get: { appState.isSidebarVisible ? .all : .detailOnly }, - set: { appState.isSidebarVisible = ($0 != .detailOnly) } - ) - } - - private var errorBinding: Binding { - Binding( - get: { appState.errorMessage != nil }, - set: { if !$0 { appState.errorMessage = nil } } - ) - } - - private var openFile: (URL) -> Void { - { url in appState.openFile(url) } + private var navigationTitle: String { + if let document = appState.documentManager.activeDocument { + return document.title + } + return appState.fileSystemManager.rootName } -} -// MARK: - Tab bar + // MARK: - Toolbar -private struct TabBar: View { - let documents: [CodeEditorCore.Document] - let appState: AppState + /// Unified toolbar: identity in the middle, creation and panels at the trailing + /// edge. Replaces the sidebar's hand-rolled header row. + @ToolbarContentBuilder + private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .principal) { + breadcrumb + } - var body: some View { - HStack(spacing: 0) { - ScrollView(.horizontal) { - HStack(spacing: 0) { - ForEach(documents) { document in - tab(for: document) - } - } - } - .scrollIndicators(.never) + ToolbarItemGroup(placement: .primaryAction) { + createMenu Button { - appState.newFile() + appState.toggleTerminal() } label: { - Image(systemName: "plus") - .font(.system(size: 11, weight: .medium)) + Image( + systemName: appState.isTerminalVisible + ? "rectangle.bottomthird.inset.filled" + : "rectangle.bottomthird.inset") } - .buttonStyle(.plain) - .padding(.horizontal, 10) - .help("New File (⌘N)") + .help(appState.isTerminalVisible ? "Hide Terminal (⌃`)" : "Show Terminal (⌃`)") } - .frame(height: 28) - .background(appState.theme.chrome.barBackground.color) } - private func tab(for document: CodeEditorCore.Document) -> some View { - let isActive = document === appState.documentManager.activeDocument + /// The project-relative path of the active document, quiet and capped. + @ViewBuilder + private var breadcrumb: some View { + let segments = appState.breadcrumb + if segments.isEmpty { + Text(appState.fileSystemManager.rootName) + .font(Typography.sectionHeader) + .foregroundStyle(.secondary) + .lineLimit(1) + } else { + HStack(spacing: Metrics.Space.compact) { + ForEach(Array(segments.enumerated()), id: \.offset) { index, segment in + if index > 0 { + Image(systemName: "chevron.right") + .font(.system(size: 8, weight: .semibold)) + .foregroundStyle(.tertiary) + } + Text(segment) + .font(Typography.sectionHeader) + .foregroundStyle( + index == segments.count - 1 ? .primary : .secondary + ) + .lineLimit(1) + } + } + } + } - return HStack(spacing: 6) { + /// One create button with a menu, instead of two icon buttons. New items land + /// beside the active document, or at the workspace root — the Xcode rule. + private var createMenu: some View { + Menu { Button { - appState.documentManager.activate(document) + appState.requestCreation(kind: .file) } label: { - HStack(spacing: 4) { - Image(systemName: document.isUntitled ? "doc" : "doc.text") - .font(.system(size: 10)) - Text(document.title) - .font(.system(size: 12)) - .lineLimit(1) - } - .foregroundColor(isActive ? appState.theme.text.color : appState.theme.secondaryText.color) + Label("New File", systemImage: "doc.badge.plus") } - .buttonStyle(.plain) + .keyboardShortcut("n", modifiers: .command) Button { - appState.close(document) + appState.requestCreation(kind: .folder) } label: { - Image(systemName: document.isModified ? "circle.fill" : "xmark") - .font(.system(size: document.isModified ? 6 : 9)) - .foregroundColor(appState.theme.secondaryText.color) + Label("New Folder", systemImage: "folder.badge.plus") } - .buttonStyle(.plain) - .help(document.isModified ? "Unsaved changes" : "Close") - } - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(isActive ? appState.theme.background.color : .clear) - .overlay(alignment: .bottom) { - Rectangle() - .fill(isActive ? appState.theme.chrome.accent.color : .clear) - .frame(height: 1) + } label: { + Image(systemName: "plus") } + .help("New File (⌘N)") + .disabled(appState.defaultCreateDirectory == nil) } -} - -// MARK: - Status bar -private struct StatusBar: View { - let appState: AppState + // MARK: - Bindings - var body: some View { - HStack(spacing: 12) { - if let document = appState.documentManager.activeDocument { - Text("Ln \(appState.editorState.caretLine + 1), Col \(appState.editorState.caretColumn + 1)") - Text("\(document.characterCount) chars") - Text("\(document.lineCount) lines") - if !appState.editorState.selectedLines.isEmpty { - Text("\(appState.editorState.selectedLines.count) lines selected") - } - - Spacer(minLength: 0) - - Text(document.language.displayName) - - if document.lineEnding != .lf { - Text(document.lineEnding == .crlf ? "CRLF" : "CR") - } - } else { - Text("No document open") - Spacer(minLength: 0) - } - } - .font(.system(size: 11)) - .foregroundColor(appState.theme.secondaryText.color) - .padding(.horizontal, 10) - .padding(.vertical, 3) - .frame(height: 20) - .background(appState.theme.chrome.barBackground.color) + private var columnVisibility: Binding { + Binding( + get: { appState.isSidebarVisible ? .all : .detailOnly }, + set: { appState.isSidebarVisible = ($0 != .detailOnly) } + ) } -} - -// MARK: - Welcome -private struct WelcomeView: View { - let appState: AppState + private var pendingCloseBinding: Binding { + Binding( + get: { appState.pendingClose != nil }, + set: { if !$0 { appState.pendingClose = nil } } + ) + } - var body: some View { - VStack(spacing: 10) { - Image(systemName: "chevron.left.forwardslash.chevron.right") - .font(.system(size: 40, weight: .light)) - .foregroundColor(appState.theme.secondaryText.color) - - Text("No Document Open") - .font(.title3) - .foregroundColor(appState.theme.text.color) - - HStack(spacing: 8) { - Button("Open Folder…") { appState.openFolder() } - Button("New File") { appState.newFile() } - } - .controlSize(.large) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(appState.theme.background.color) + private var errorBinding: Binding { + Binding( + get: { appState.errorMessage != nil }, + set: { if !$0 { appState.errorMessage = nil } } + ) } } diff --git a/Sources/CodeEditorUI/AppState.swift b/Sources/CodeEditorUI/AppState.swift index b13d908..424bfe1 100644 --- a/Sources/CodeEditorUI/AppState.swift +++ b/Sources/CodeEditorUI/AppState.swift @@ -24,6 +24,10 @@ public final class AppState { /// regardless of whether the toolbar menu or a context menu requested it. public var pendingCreation: PendingCreation? + /// A modified document the user asked to close. Presented as one confirmation + /// regardless of whether the tab strip or the menu asked. + public var pendingClose: CodeEditorCore.Document? + public init() { let fileSystemManager = FileSystemManager() let themeManager = ThemeManager() @@ -131,42 +135,55 @@ public final class AppState { } } - public func close(_ document: CodeEditorCore.Document) { + /// Ask to close a document. Clean documents close immediately; modified ones + /// are parked in `pendingClose` for the window's confirmation dialog. + public func requestClose(_ document: CodeEditorCore.Document) { guard document.isModified else { documentManager.close(document) return } + pendingClose = document + } - Task { - let alert = NSAlert() - alert.messageText = "Save changes to \(document.name)?" - alert.informativeText = "Your changes will be lost if you don't save them." - alert.addButton(withTitle: "Save") - alert.addButton(withTitle: "Don't Save") - alert.addButton(withTitle: "Cancel") - - switch alert.runModal() { - case .alertFirstButtonReturn: - await saveThenClose(document) - case .alertSecondButtonReturn: - documentManager.close(document) - default: - break + /// Resolve a pending close. `save` runs the save flow first; saving an untitled + /// document opens a location prompt, and a cancelled prompt aborts the close. + public func resolvePendingClose(save: Bool) async { + guard let document = pendingClose else { return } + pendingClose = nil + + if save { + if document.isUntitled { + await promptForSaveLocation(for: document) + // A cancelled save panel leaves the document unsaved; keep it open. + guard !document.isModified else { return } + } else { + await performSave(document) + guard !document.isModified else { return } } } + documentManager.close(document) } - private func saveThenClose(_ document: CodeEditorCore.Document) async { - if document.isUntitled { - await promptForSaveLocation(for: document) - } else { - await performSave(document) - } - documentManager.close(document) + public func close(_ document: CodeEditorCore.Document) { + requestClose(document) } // MARK: - Commands + /// Segments of the active document's path relative to the workspace root, for + /// the toolbar breadcrumb. Capped to the last three so deep paths stay legible. + public var breadcrumb: [String] { + guard let url = documentManager.activeDocument?.url else { return [] } + let path = url.standardizedFileURL.path + if let rootPath = fileSystemManager.rootURL?.standardizedFileURL.path, + path.hasPrefix(rootPath + "/") + { + let segments = path.dropFirst(rootPath.count + 1).split(separator: "/").map(String.init) + return Array(segments.suffix(3)) + } + return [url.lastPathComponent] + } + /// Where new items land when no directory is given: beside the active /// document, or at the workspace root. The Xcode rule. public var defaultCreateDirectory: URL? { diff --git a/Sources/CodeEditorUI/Sources/Chrome/EmptyStateView.swift b/Sources/CodeEditorUI/Sources/Chrome/EmptyStateView.swift new file mode 100644 index 0000000..1eef464 --- /dev/null +++ b/Sources/CodeEditorUI/Sources/Chrome/EmptyStateView.swift @@ -0,0 +1,87 @@ +import CodeEditorCore +import CodeEditorThemes +import SwiftUI + +/// The window's single empty state, shown when no document is active. +/// +/// Adapts to what is missing: with a workspace open it points at the sidebar, which +/// is where the action is; with nothing open it offers the two ways to start. The +/// previous UI showed both messages at once, one per pane, with different +/// capitalisation and duplicate buttons. +public struct EmptyStateView: View { + let appState: AppState + let onOpenFolder: () -> Void + let onNewFile: () -> Void + + public init( + appState: AppState, + onOpenFolder: @escaping () -> Void, + onNewFile: @escaping () -> Void + ) { + self.appState = appState + self.onOpenFolder = onOpenFolder + self.onNewFile = onNewFile + } + + private var hasWorkspace: Bool { appState.fileSystemManager.rootURL != nil } + + public var body: some View { + VStack(spacing: Metrics.Space.loose) { + mark + + if hasWorkspace { + Text("No Document Open") + .font(Typography.emptyTitle) + .foregroundStyle(appState.theme.text.color) + + Text("Select a file in the sidebar, or press ⇧⌘O to open one.") + .font(Typography.emptyBody) + .foregroundStyle(appState.theme.secondaryText.color) + } else { + Text("Open a Folder") + .font(Typography.emptyTitle) + .foregroundStyle(appState.theme.text.color) + + Text("Browse and edit a project, or start from an empty file.") + .font(Typography.emptyBody) + .foregroundStyle(appState.theme.secondaryText.color) + + actions + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(appState.theme.background.color) + } + + /// The app mark: the code glyph on a soft accent plate. One memorable element, + /// everything else stays quiet. + private var mark: some View { + Image(systemName: "chevron.left.forwardslash.chevron.right") + .font(.system(size: 32, weight: .medium)) + .foregroundStyle(appState.theme.chrome.accent.color) + .frame(width: 72, height: 72) + .background( + RoundedRectangle(cornerRadius: Metrics.Radius.panel, style: .continuous) + .fill(appState.theme.chrome.accent.color.opacity(0.12)) + ) + .accessibilityHidden(true) + } + + /// One primary action; the secondary is visually and structurally subordinate. + private var actions: some View { + HStack(spacing: Metrics.Space.regular) { + Button(action: onOpenFolder) { + Label("Open Folder…", systemImage: "folder") + } + .controlSize(.large) + .buttonStyle(.borderedProminent) + .keyboardShortcut("o", modifiers: .command) + + Button(action: onNewFile) { + Text("New File") + } + .controlSize(.large) + } + .padding(.top, Metrics.Space.compact) + } +} diff --git a/Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift b/Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift new file mode 100644 index 0000000..7ba24de --- /dev/null +++ b/Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift @@ -0,0 +1,71 @@ +import CodeEditorCore +import CodeEditorThemes +import SwiftUI + +/// The readout strip under the editor: caret position, selection, then — pushed to +/// the trailing edge — encoding, line ending and language. +/// +/// The byte count is gone; it flattered the machine and told the reader nothing. +public struct StatusBarView: View { + let appState: AppState + + public init(appState: AppState) { + self.appState = appState + } + + public var body: some View { + HStack(spacing: 0) { + if let document = appState.documentManager.activeDocument { + item("Ln \(appState.editorState.caretLine + 1), Col \(appState.editorState.caretColumn + 1)") + + if !appState.editorState.selectedLines.isEmpty { + separator + let count = appState.editorState.selectedLines.count + item(count == 1 ? "1 line selected" : "\(count) lines selected") + } + + Spacer(minLength: 0) + + item("UTF-8") + separator + item(document.lineEnding == .lf ? "LF" : document.lineEnding == .crlf ? "CRLF" : "CR") + separator + item(document.language.displayName) + } else { + Spacer(minLength: 0) + } + } + .font(Typography.statusBar) + .foregroundStyle(appState.theme.secondaryText.color) + .padding(.horizontal, Metrics.Space.regular) + .frame(height: Metrics.Height.statusBar) + .background(backgroundFill) + .overlay(alignment: .top) { + Rectangle() + .fill(appState.theme.chrome.border.color) + .frame(height: 1) + } + } + + @ViewBuilder + private var backgroundFill: some View { + if appState.theme.chrome.usesMaterials { + Rectangle().fill(.thinMaterial) + } else { + Rectangle().fill(appState.theme.chrome.barBackground.color) + } + } + + private var separator: some View { + Rectangle() + .fill(appState.theme.chrome.border.color) + .frame(width: 1, height: 9) + .padding(.horizontal, Metrics.Space.regular) + } + + private func item(_ label: String) -> some View { + Text(label) + .padding(.vertical, 2) + .fixedSize() + } +} diff --git a/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift b/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift new file mode 100644 index 0000000..b2b9d43 --- /dev/null +++ b/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift @@ -0,0 +1,174 @@ +import CodeEditorCore +import CodeEditorThemes +import SwiftUI + +/// The tab strip above the editor. +/// +/// The active tab's fill equals the canvas and its top corners are rounded, so it +/// reads as the top of the editor rather than a chip in a bar — the Xcode treatment. +/// The strip's bottom hairline runs behind the tabs; the active tab covers its own +/// segment, which is what makes the merge visible. +/// +/// Every tab always offers a close button. The dirty state is a dot beside the +/// title, not a replacement for the button: swapping `×` for a dot made modified +/// tabs impossible to close without saving first. +public struct TabBarView: View { + let documents: [CodeEditorCore.Document] + let activeDocument: CodeEditorCore.Document? + let theme: Theme + let onSelect: (CodeEditorCore.Document) -> Void + let onClose: (CodeEditorCore.Document) -> Void + let onNew: () -> Void + + @State private var hoveredDocument: CodeEditorCore.Document? + + public init( + documents: [CodeEditorCore.Document], + activeDocument: CodeEditorCore.Document?, + theme: Theme, + onSelect: @escaping (CodeEditorCore.Document) -> Void, + onClose: @escaping (CodeEditorCore.Document) -> Void, + onNew: @escaping () -> Void + ) { + self.documents = documents + self.activeDocument = activeDocument + self.theme = theme + self.onSelect = onSelect + self.onClose = onClose + self.onNew = onNew + } + + public var body: some View { + ZStack(alignment: .bottom) { + stripFill + stripBorder + + HStack(spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 2) { + ForEach(documents) { document in + tab(for: document) + } + } + .padding(.horizontal, Metrics.Space.compact) + } + + newTabButton + .padding(.horizontal, Metrics.Space.regular) + } + .frame(height: Metrics.Height.tabStrip) + } + .frame(height: Metrics.Height.tabStrip) + } + + // MARK: - Strip + + /// Materials when the palette allows them; the token fill otherwise. + @ViewBuilder + private var stripFill: some View { + if theme.chrome.usesMaterials { + Rectangle().fill(.thinMaterial) + } else { + Rectangle().fill(theme.chrome.barBackground.color) + } + } + + private var stripBorder: some View { + Rectangle() + .fill(theme.chrome.border.color) + .frame(height: 1) + } + + private var newTabButton: some View { + Button(action: onNew) { + Image(systemName: "plus") + .font(.system(size: 11, weight: .medium)) + .frame(width: Metrics.minimumHitTarget - 8, height: Metrics.minimumHitTarget - 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("New File (⌘N)") + } + + // MARK: - Tabs + + private func tab(for document: CodeEditorCore.Document) -> some View { + let isActive = document === activeDocument + let isHovered = hoveredDocument?.id == document.id + + return HStack(spacing: Metrics.Space.compact) { + if document.isModified { + Circle() + .fill(isActive ? theme.chrome.accent.color : theme.secondaryText.color) + .frame(width: 6, height: 6) + .help("Unsaved changes") + } + + Text(document.title) + .font(Typography.tabLabel) + .lineLimit(1) + .truncationMode(.middle) + + closeButton(for: document, isActive: isActive, isHovered: isHovered) + } + .padding(.leading, Metrics.Space.regular) + .padding(.trailing, isActive || isHovered ? Metrics.Space.compact : Metrics.Space.regular + 8) + .padding(.vertical, Metrics.Space.compact) + .foregroundStyle(isActive ? theme.text.color : theme.secondaryText.color) + .background(tabBackground(isActive: isActive, isHovered: isHovered)) + .clipShape( + UnevenRoundedRectangle( + topLeadingRadius: Metrics.Radius.row, + topTrailingRadius: Metrics.Radius.row + ) + ) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.1)) { + hoveredDocument = hovering ? document : (hoveredDocument?.id == document.id ? nil : hoveredDocument) + } + } + .contentShape(Rectangle()) + .onTapGesture { onSelect(document) } + .help(document.name) + } + + /// The close affordance. Always present so layout never shifts; invisible on + /// unhovered inactive tabs so the strip stays quiet, but never replaced by the + /// dirty dot. + private func closeButton( + for document: CodeEditorCore.Document, + isActive: Bool, + isHovered: Bool + ) -> some View { + Button { + onClose(document) + } label: { + Image(systemName: "xmark") + .font(.system(size: 8, weight: .bold)) + .frame(width: 14, height: 14) + .background( + Circle().fill( + (isActive || isHovered) ? theme.chrome.rowHover.color : .clear + ) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .opacity(isActive || isHovered ? 1 : 0) + .disabled(!isActive && !isHovered) + .help(document.isModified ? "Close (unsaved changes)" : "Close") + } + + @ViewBuilder + private func tabBackground(isActive: Bool, isHovered: Bool) -> some View { + if isActive { + // Matches the canvas, so the tab becomes the top of the editor. + theme.background.color + } else if isHovered { + theme.chrome.tabHoverBackground.color + } else { + Color.clear + } + } +} From 4005612100847ffc4c59e0c3642d9303c5182ac6 Mon Sep 17 00:00:00 2001 From: yan Date: Fri, 9 Oct 2026 20:56:00 +0300 Subject: [PATCH 09/22] feat(core): file outline, fuzzy matcher and quick-open index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FileOutline extracts the sidebar's tree flattening behind closures so the traversal — depth cap, expansion recursion — is testable without a window server. QuickOpenMatcher is a greedy-earliest subsequence scorer with weights for filename over directory matches, segment starts and consecutive runs; highlight ranges are UTF-16 offsets into the original string. QuickOpenIndex walks the workspace off the main actor in batches, skips .git/.build/node_modules/DerivedData/Pods, caps at 20k entries, and drops results from a superseded walk via a generation counter. The index is a snapshot, not an FSEvents stream: it refreshes on reindex, so new files are not findable until then. Recorded as a limitation on the type. --- .../Sources/FileSystem/FileOutline.swift | 53 ++++ .../FileSystem/FileSystemManager.swift | 2 +- .../Sources/QuickOpen/QuickOpenIndex.swift | 141 +++++++++++ .../Sources/QuickOpen/QuickOpenMatcher.swift | 142 +++++++++++ .../Sources/Sidebar/FileExplorerView.swift | 34 +-- Tests/CodeEditorTests/QuickOpenTests.swift | 233 ++++++++++++++++++ 6 files changed, 577 insertions(+), 28 deletions(-) create mode 100644 Sources/CodeEditorCore/Sources/FileSystem/FileOutline.swift create mode 100644 Sources/CodeEditorCore/Sources/QuickOpen/QuickOpenIndex.swift create mode 100644 Sources/CodeEditorCore/Sources/QuickOpen/QuickOpenMatcher.swift create mode 100644 Tests/CodeEditorTests/QuickOpenTests.swift diff --git a/Sources/CodeEditorCore/Sources/FileSystem/FileOutline.swift b/Sources/CodeEditorCore/Sources/FileSystem/FileOutline.swift new file mode 100644 index 0000000..fe0cb85 --- /dev/null +++ b/Sources/CodeEditorCore/Sources/FileSystem/FileOutline.swift @@ -0,0 +1,53 @@ +import Foundation + +/// Flattening an expanded directory tree into displayable rows. +/// +/// Extracted from the sidebar view so the traversal — depth cap, expansion +/// recursion, directory-first ordering — is testable without a window server. +/// Only expanded branches are enumerated, so a large workspace costs nothing +/// until it is opened. +public enum FileOutline { + /// A single visible row: the item plus how deep it sits in the expanded tree. + public struct Row: Identifiable, Sendable, Equatable { + public let item: FileSystemItem + public let depth: Int + + public var id: URL { item.url } + + public init(item: FileSystemItem, depth: Int) { + self.item = item + self.depth = depth + } + } + + /// Flatten `directory` recursively. + /// + /// - Parameters: + /// - children: returns the sorted children of a directory. + /// - isExpanded: whether a directory's children should be included. + /// - depthLimit: a hard cap that guards against symlink cycles. The cap is a + /// depth, not a node count, so it cannot be exhausted by a wide tree. + public static func rows( + from directory: URL, + children: (URL) -> [FileSystemItem], + isExpanded: (URL) -> Bool, + depthLimit: Int = 32 + ) -> [Row] { + func walk(_ directory: URL, depth: Int, remaining: Int) -> [Row] { + guard remaining > 0 else { return [] } + + var result: [Row] = [] + for item in children(directory) { + result.append(Row(item: item, depth: depth)) + if item.isDirectory, isExpanded(item.url) { + result.append( + contentsOf: walk(item.url, depth: depth + 1, remaining: remaining - 1) + ) + } + } + return result + } + + return walk(directory, depth: 0, remaining: depthLimit) + } +} diff --git a/Sources/CodeEditorCore/Sources/FileSystem/FileSystemManager.swift b/Sources/CodeEditorCore/Sources/FileSystem/FileSystemManager.swift index c603f5a..14963c1 100644 --- a/Sources/CodeEditorCore/Sources/FileSystem/FileSystemManager.swift +++ b/Sources/CodeEditorCore/Sources/FileSystem/FileSystemManager.swift @@ -105,7 +105,7 @@ public enum FileSystemError: LocalizedError { // MARK: - FileSystemItem /// A file or directory in the file system. -public struct FileSystemItem: Identifiable, Equatable, Hashable { +public struct FileSystemItem: Identifiable, Equatable, Hashable, Sendable { public let url: URL public let name: String public let isDirectory: Bool diff --git a/Sources/CodeEditorCore/Sources/QuickOpen/QuickOpenIndex.swift b/Sources/CodeEditorCore/Sources/QuickOpen/QuickOpenIndex.swift new file mode 100644 index 0000000..01eea19 --- /dev/null +++ b/Sources/CodeEditorCore/Sources/QuickOpen/QuickOpenIndex.swift @@ -0,0 +1,141 @@ +import Foundation + +// MARK: - IndexedFile + +/// One file known to the quick-open index. +public struct IndexedFile: Equatable, Sendable { + public let url: URL + public let relativePath: String + + public init(url: URL, relativePath: String) { + self.url = url + self.relativePath = relativePath + } +} + +// MARK: - QuickOpenIndex + +/// Walks the workspace for quick-open, off the main actor, in bounded batches. +/// +/// The walk skips dependency and build directories — `.git`, `.build`, `build`, +/// `node_modules`, `DerivedData`, `.swiftpm` — and dot-directories generally, and +/// stops at `maxEntries` so a runaway tree cannot wedge the app. +/// +/// Re-indexing cancels the previous walk: each batch is published only if it was +/// collected under the current generation, so a stale walk's results are dropped +/// rather than mixed in. +/// +/// Scope limitation, deliberate: the index is a filesystem walk, not an FSEvents +/// stream. It refreshes when the workspace root changes or when `reindex` is +/// called; files created after the last walk are not findable until then. +@MainActor +@Observable +public final class QuickOpenIndex { + public private(set) var entries: [IndexedFile] = [] + public private(set) var isIndexing = false + + private let provider: FileSystemProvider + private var generation = 0 + + /// Directories whose contents are never worth listing. + private nonisolated static let skippedNames: Set = [ + ".git", ".build", "build", "node_modules", "DerivedData", ".swiftpm", "Pods", + ] + + private nonisolated static let maxEntries = 20_000 + private nonisolated static let maxDepth = 16 + + public init(provider: FileSystemProvider = DefaultFileSystemProvider()) { + self.provider = provider + } + + /// Rebuild the index for `root`. The previous walk, if any, is cancelled. + public func reindex(root: URL) { + generation += 1 + let current = generation + entries = [] + isIndexing = true + + let provider = provider + Task { + await Self.walk(root: root, provider: provider) { [weak self] batch in + await MainActor.run { + guard let self, self.generation == current else { return false } + self.entries.append(contentsOf: batch) + return true + } + } + await MainActor.run { [weak self] in + guard let self, self.generation == current else { return } + self.isIndexing = false + } + } + } + + /// Top matches for a query, highest score first, ties broken by path length. + public func search(_ query: String, limit: Int = 20) -> [QuickOpenMatch] { + guard !query.isEmpty else { return [] } + + var matches: [QuickOpenMatch] = [] + matches.reserveCapacity(min(entries.count, 256)) + for entry in entries { + if let match = QuickOpenMatcher.score( + query: query, + relativePath: entry.relativePath, + url: entry.url + ) { + matches.append(match) + } + } + + matches.sort { + if $0.score != $1.score { return $0.score > $1.score } + return $0.relativePath.count == $1.relativePath.count + ? $0.relativePath < $1.relativePath + : $0.relativePath.count < $1.relativePath.count + } + return Array(matches.prefix(limit)) + } + + /// Breadth-first walk that yields files in batches. The handler returns false + /// to cancel — used when a newer generation has taken over. + private nonisolated static func walk( + root: URL, + provider: FileSystemProvider, + yield: @Sendable ([IndexedFile]) async -> Bool + ) async { + var queue: [(url: URL, depth: Int, prefix: String)] = [(root, 0, "")] + var collected: [IndexedFile] = [] + var cancelled = false + + while !queue.isEmpty, !cancelled, collected.count < maxEntries { + let (directory, depth, prefix) = queue.removeFirst() + guard depth < maxDepth else { continue } + guard let contents = try? provider.contentsOfDirectory(at: directory) else { continue } + + var batch: [IndexedFile] = [] + for item in contents { + let name = item.lastPathComponent + if name.hasPrefix(".") { continue } + if provider.isDirectory(at: item) { + guard !skippedNames.contains(name) else { continue } + queue.append((item, depth + 1, prefix.isEmpty ? name : prefix + "/" + name)) + } else { + batch.append( + IndexedFile( + url: item, + relativePath: prefix.isEmpty ? name : prefix + "/" + name + ) + ) + } + } + + if !batch.isEmpty { + collected.append(contentsOf: batch) + if await yield(batch) == false { + cancelled = true + } + } + } + } +} diff --git a/Sources/CodeEditorCore/Sources/QuickOpen/QuickOpenMatcher.swift b/Sources/CodeEditorCore/Sources/QuickOpen/QuickOpenMatcher.swift new file mode 100644 index 0000000..7788dd8 --- /dev/null +++ b/Sources/CodeEditorCore/Sources/QuickOpen/QuickOpenMatcher.swift @@ -0,0 +1,142 @@ +import Foundation + +// MARK: - QuickOpenMatch + +/// One scored candidate from a quick-open search. +/// +/// `highlightRanges` are UTF-16 offset ranges into `relativePath`, matching +/// `NSAttributedString`/`AttributedString` semantics, so the view can emphasise +/// the matched characters without re-deriving them. +public struct QuickOpenMatch: Equatable, Sendable { + public let url: URL + public let relativePath: String + public let score: Int + public let highlightRanges: [Range] + + public init(url: URL, relativePath: String, score: Int, highlightRanges: [Range]) { + self.url = url + self.relativePath = relativePath + self.score = score + self.highlightRanges = highlightRanges + } +} + +// MARK: - QuickOpenMatcher + +/// Subsequence scoring for quick-open queries. +/// +/// A query matches when its characters appear in the candidate path in order, +/// case- and diacritic-insensitively. Scoring then prefers, in rough order of +/// weight: matches inside the file name over matches in the directories, +/// matches at the start of a path segment, runs of consecutive characters, and +/// shorter paths over longer ones. +/// +/// Matching is greedy-earliest: each query character consumes the next +/// occurrence in the path. That is not the globally optimal assignment, but it +/// is deterministic, cheap, and testable — properties a keystroke-time scorer +/// needs more than the last few points of ranking quality. +public enum QuickOpenMatcher { + /// Score `query` against `relativePath`. Returns nil when the query is empty + /// or is not a subsequence of the path. + public static func score(query: String, relativePath: String, url: URL) -> QuickOpenMatch? { + guard !query.isEmpty else { return nil } + + let pathCharacters = Array(relativePath) + let foldedPath = pathCharacters.map { fold(String($0)) } + let foldedQuery = Array(query).map { fold(String($0)) } + + // Greedy-earliest subsequence over the folded characters. + var positions: [Int] = [] + positions.reserveCapacity(foldedQuery.count) + var cursor = 0 + for wanted in foldedQuery where !wanted.isEmpty { + var found: Int? + var index = cursor + while index < foldedPath.count { + if foldedPath[index] == wanted { + found = index + break + } + index += 1 + } + guard let position = found else { return nil } + positions.append(position) + cursor = position + 1 + } + + return QuickOpenMatch( + url: url, + relativePath: relativePath, + score: score(positions: positions, in: pathCharacters), + highlightRanges: highlightRanges(positions: positions, in: pathCharacters) + ) + } + + // MARK: - Scoring + + private enum Weight { + static let perMatch = 10 + static let consecutive = 12 + static let segmentStart = 14 + static let afterSeparator = 8 + static let inFilename = 6 + static let filenameClutter = 1 + static let pathLength = 1 + static let clutterCap = 8 + } + + private static func score(positions: [Int], in characters: [Character]) -> Int { + let filenameStart = characters.lastIndex(of: "/").map { $0 + 1 } ?? 0 + var total = 0 + var matchedInFilename = 0 + + for (ordinal, position) in positions.enumerated() { + total += Weight.perMatch + + if ordinal > 0, position == positions[ordinal - 1] + 1 { + total += Weight.consecutive + } + + if position == 0 || characters[position - 1] == "/" { + total += Weight.segmentStart + } else if "/._- ".contains(characters[position - 1]) { + total += Weight.afterSeparator + } + + if position >= filenameStart { + total += Weight.inFilename + matchedInFilename += 1 + } + } + + // Unmatched characters in the file name dilute the match. + let filenameLength = characters.count - filenameStart + let clutter = max(0, filenameLength - matchedInFilename) + total -= min(Weight.clutterCap, clutter * Weight.filenameClutter) + + // Shorter paths read as more direct; a small tie-breaker. + total -= characters.count / 8 + + return total + } + + /// UTF-16 offset ranges of the matched characters in the original string. + private static func highlightRanges(positions: [Int], in characters: [Character]) -> [Range] { + var offsets: [Int] = [] + offsets.reserveCapacity(characters.count + 1) + var running: Int = 0 + for character in characters { + offsets.append(running) + running += String(character).utf16.count + } + offsets.append(running) + + return positions.map { offsets[$0].. String { + character.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil) + } +} diff --git a/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift b/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift index 5cd9a7b..a450bd8 100644 --- a/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift +++ b/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift @@ -92,7 +92,13 @@ public struct FileExplorerView: View { private func list(for root: URL) -> some View { List(selection: $selection) { - ForEach(flattenedRows(from: root)) { row in + ForEach( + FileOutline.rows( + from: root, + children: fileSystemManager.children(of:), + isExpanded: fileSystemManager.isExpanded + ) + ) { row in rowView(row.item) .tag(row.item) .padding(.leading, CGFloat(row.depth) * Metrics.treeIndentPerLevel) @@ -101,22 +107,6 @@ public struct FileExplorerView: View { .listStyle(.sidebar) } - /// Flatten the expanded tree into indentable rows. Only expanded branches are - /// enumerated, so a large workspace costs nothing until it is opened. - private func flattenedRows(from directory: URL, depth: Int = 0) -> [FileRow] { - guard depth < 32 else { return [] } - - return fileSystemManager.children(of: directory).flatMap { item -> [FileRow] in - guard item.isDirectory else { return [FileRow(item: item, depth: depth)] } - - var result = [FileRow(item: item, depth: depth)] - if fileSystemManager.isExpanded(item.url) { - result.append(contentsOf: flattenedRows(from: item.url, depth: depth + 1)) - } - return result - } - } - private func rowView(_ item: FileSystemItem) -> some View { let isActiveDocument = item.url == activeDocumentURL @@ -261,16 +251,6 @@ public struct FileExplorerView: View { } } -// MARK: - Tree rows - -/// A single visible row: the item plus how deep it sits in the expanded tree. -private struct FileRow: Identifiable { - let item: FileSystemItem - let depth: Int - - var id: URL { item.url } -} - // MARK: - Notifications extension Notification.Name { diff --git a/Tests/CodeEditorTests/QuickOpenTests.swift b/Tests/CodeEditorTests/QuickOpenTests.swift new file mode 100644 index 0000000..518065a --- /dev/null +++ b/Tests/CodeEditorTests/QuickOpenTests.swift @@ -0,0 +1,233 @@ +import CodeEditorCore +import Foundation +import Testing + +// MARK: - QuickOpenMatcher + +@Suite("QuickOpenMatcher") +struct QuickOpenMatcherTests { + private func match(_ query: String, _ path: String) -> QuickOpenMatch? { + QuickOpenMatcher.score(query: query, relativePath: path, url: URL(fileURLWithPath: path)) + } + + @Test("empty query never matches") + func emptyQuery() { + #expect(match("", "Sources/Main.swift") == nil) + } + + @Test("a non-subsequence is rejected") + func rejectsNonSubsequence() { + #expect(match("zqx", "Sources/Main.swift") == nil) + #expect(match("swift", "Sources/Main.m") == nil) + } + + @Test("matching is case-insensitive and order-preserving") + func caseInsensitiveSubsequence() { + #expect(match("MAIN", "Sources/Main.swift") != nil) + #expect(match("smwft", "Sources/Main.swift") != nil) + } + + @Test("filename matches outrank directory matches") + func filenameBeatsDirectory() { + let inFilename = match("main", "Sources/Main.swift") + let inDirectory = match("main", "Sources/main/View.swift") + #expect(inFilename != nil) + #expect(inDirectory != nil) + #expect(inFilename!.score > inDirectory!.score) + } + + @Test("consecutive runs outrank scattered matches") + func consecutiveBeatsScattered() { + let consecutive = match("main", "Sources/Main.swift") + let scattered = match("mi", "Sources/Main.swift") + #expect(consecutive != nil) + #expect(scattered != nil) + // Four consecutive characters carry more signal than two scattered ones. + #expect(consecutive!.score > scattered!.score) + } + + @Test("highlight ranges point at the matched characters") + func highlightRanges() throws { + let result = try #require(match("main", "Sources/Main.swift")) + #expect(result.highlightRanges.count == 4) + + let path = result.relativePath + let view = path.utf16 + let highlighted = result.highlightRanges.compactMap { range -> String? in + let start = view.index(view.startIndex, offsetBy: range.lowerBound) + let end = view.index(start, offsetBy: range.count) + return String(decoding: view[start.. FileSystemItem { + FileSystemItem(url: URL(fileURLWithPath: path), isDirectory: directory) + } + + @Test("only expanded branches appear, with correct depths") + func expansionAndDepth() { + let tree: [String: [FileSystemItem]] = [ + "/root": [ + item("/root/Sources", directory: true), + item("/root/README.md", directory: false), + ], + "/root/Sources": [ + item("/root/Sources/App", directory: true) + ], + "/root/Sources/App": [ + item("/root/Sources/App/Main.swift", directory: false) + ], + ] + let expanded: Set = ["/root", "/root/Sources", "/root/Sources/App"] + + let rows = FileOutline.rows( + from: URL(fileURLWithPath: "/root"), + children: { tree[$0.standardizedFileURL.path] ?? [] }, + isExpanded: { expanded.contains($0.standardizedFileURL.path) } + ) + + #expect(rows.map(\.item.name) == ["Sources", "App", "Main.swift", "README.md"]) + #expect(rows.map(\.depth) == [0, 1, 2, 0]) + } + + @Test("collapsed branches contribute only their header row") + func collapsedBranch() { + let tree: [String: [FileSystemItem]] = [ + "/root": [item("/root/Sources", directory: true)], + "/root/Sources": [item("/root/Sources/Main.swift", directory: false)], + ] + + let rows = FileOutline.rows( + from: URL(fileURLWithPath: "/root"), + children: { tree[$0.standardizedFileURL.path] ?? [] }, + isExpanded: { _ in false } + ) + + #expect(rows.map(\.item.name) == ["Sources"]) + #expect(rows.map(\.depth) == [0]) + } + + @Test("the depth cap terminates symlink-style cycles") + func depthCapTerminates() { + // Every directory contains the same directory again, forever. + let loop = item("/root/loop", directory: true) + let rows = FileOutline.rows( + from: URL(fileURLWithPath: "/root"), + children: { _ in [loop] }, + isExpanded: { _ in true }, + depthLimit: 4 + ) + + #expect(rows.count == 4) + #expect(rows.map(\.depth) == [0, 1, 2, 3]) + } +} + +// MARK: - QuickOpenIndex + +@Suite("QuickOpenIndex") +@MainActor +struct QuickOpenIndexTests { + /// In-memory provider: a set of directories and files keyed by path. + private struct StubProvider: FileSystemProvider, @unchecked Sendable { + let directories: Set + let files: Set + + private func contents(_ path: String) -> [URL] { + let childPrefix = path == "/" ? "/" : path + "/" + let names = Set( + (directories.union(files)) + .filter { $0.hasPrefix(childPrefix) && !$0.dropFirst(childPrefix.count).contains("/") } + .map { String($0.dropFirst(childPrefix.count)) } + ) + return + names + .map { URL(fileURLWithPath: childPrefix + $0, isDirectory: directories.contains(childPrefix + $0)) } + .sorted { $0.path < $1.path } + } + + func contentsOfDirectory(at url: URL) throws -> [URL] { contents(url.standardizedFileURL.path) } + func fileExists(at url: URL) -> Bool { + let path = url.standardizedFileURL.path + return files.contains(path) || directories.contains(path) + } + + func isDirectory(at url: URL) -> Bool { directories.contains(url.standardizedFileURL.path) } + func createDirectory(at url: URL) throws {} + func createFile(at url: URL, contents: Data) throws {} + func removeItem(at url: URL) throws {} + func moveItem(at srcURL: URL, to dstURL: URL) throws {} + func readFile(at url: URL) throws -> Data { Data() } + func writeFile(at url: URL, data: Data) throws {} + func attributesOfItem(at url: URL) throws -> [FileAttributeKey: Any] { [:] } + } + + private static let provider = StubProvider( + directories: [ + "/root", "/root/Sources", "/root/Sources/App", "/root/.git", "/root/node_modules", + "/root/node_modules/pkg", "/root/Tests", + ], + files: [ + "/root/Sources/App/Main.swift", "/root/Sources/App/Model.swift", + "/root/Sources/Lib.swift", "/root/Tests/MainTests.swift", "/root/README.md", + ] + ) + + @Test("the walk collects files with paths relative to the root") + func collectsRelativePaths() async throws { + let index = QuickOpenIndex(provider: Self.provider) + index.reindex(root: URL(fileURLWithPath: "/root")) + + // The walk is async; poll briefly until it publishes and settles. + for _ in 0..<50 where index.isIndexing { + try await Task.sleep(for: .milliseconds(10)) + } + + let paths = Set(index.entries.map(\.relativePath)) + #expect(paths.contains("Sources/App/Main.swift")) + #expect(paths.contains("README.md")) + #expect(paths.count == 5) + } + + @Test("dependency and dot directories are skipped") + func skipsBuildDirectories() async throws { + let index = QuickOpenIndex(provider: Self.provider) + index.reindex(root: URL(fileURLWithPath: "/root")) + for _ in 0..<50 where index.isIndexing { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(!index.entries.contains { $0.relativePath.contains(".git") }) + #expect(!index.entries.contains { $0.relativePath.contains("node_modules") }) + } + + @Test("search ranks a filename hit first") + func searchRanks() async throws { + let index = QuickOpenIndex(provider: Self.provider) + index.reindex(root: URL(fileURLWithPath: "/root")) + for _ in 0..<50 where index.isIndexing { + try await Task.sleep(for: .milliseconds(10)) + } + + let results = index.search("maint") + #expect(!results.isEmpty) + #expect(results.first?.relativePath == "Tests/MainTests.swift") + } + + @Test("empty query returns nothing") + func emptyQuery() async { + let index = QuickOpenIndex(provider: Self.provider) + #expect(index.search("").isEmpty) + } +} From fd2dbc96740ed29a49916cdda6793b7b5644b2ef Mon Sep 17 00:00:00 2001 From: yan Date: Fri, 9 Oct 2026 21:21:26 +0300 Subject: [PATCH 10/22] feat(ui): find, quick open, a resizable panel and session restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Find and replace reuses CodeEdit's own panel: the upstream coordinator calls showFindPanel() when SourceEditorState.findPanelVisible disagrees with the panel, so ⌘F sets a flag and the panel keeps itself the authority on visibility. The editor view syncs both directions. The editor now sets its peripherals explicitly. They were previously left at upstream defaults, which silently enabled the minimap and the folding ribbon. Minimap is off by default with a toggle; invisibles and the folding ribbon are toggles too; line height moves to 1.35 from 1.2; bracket emphasis becomes a persistent underline; smart quotes and invisible spaces are flagged as warnings. Quick open (⌘P) scores a bounded filesystem index and shows matched characters emphasised over the dimmed path. It is a snapshot, not an FSEvents stream, so files created after the last walk are not findable until the next reindex — recorded on the index type. The terminal panel is drag-resized between 120 and 600 points and persists, replacing a fixed 220. Its header drops the raw red/green dot and the text Clear/Restart buttons for a semantic status dot and icon buttons. Output selection is enabled so text can be copied. The window becomes a single Window rather than a WindowGroup; a second window would have shared one AppState, one document set and one sidebar. On launch the workspace root, open tabs and active tab are restored from UserDefaults. Untitled documents are not restored — they have no stable identity across launches. --- .../CodeEditorApp/Sources/CodeEditorApp.swift | 44 +++- .../Sources/MainWindowView.swift | 83 ++++++- .../Sources/Session/SessionStore.swift | 47 ++++ Sources/CodeEditorUI/AppState.swift | 122 +++++++++- .../Sources/Editor/CodeEditorView.swift | 49 +++- .../Sources/Editor/EditorState.swift | 36 ++- .../Sources/Overlays/QuickOpenView.swift | 214 ++++++++++++++++++ .../Sources/Terminal/TerminalView.swift | 92 ++++++-- Tests/CodeEditorTests/SessionStoreTests.swift | 55 +++++ 9 files changed, 707 insertions(+), 35 deletions(-) create mode 100644 Sources/CodeEditorCore/Sources/Session/SessionStore.swift create mode 100644 Sources/CodeEditorUI/Sources/Overlays/QuickOpenView.swift create mode 100644 Tests/CodeEditorTests/SessionStoreTests.swift diff --git a/Sources/CodeEditorApp/Sources/CodeEditorApp.swift b/Sources/CodeEditorApp/Sources/CodeEditorApp.swift index c53bdb7..d0717f5 100644 --- a/Sources/CodeEditorApp/Sources/CodeEditorApp.swift +++ b/Sources/CodeEditorApp/Sources/CodeEditorApp.swift @@ -8,7 +8,10 @@ struct CodeEditorApp: App { @State private var appState = AppState() var body: some Scene { - WindowGroup("Code Editor", id: "main-window") { + // A single window: AppState is shared and per-window state would fight it. + // A second window sharing one document set and one sidebar is a bug, not a + // feature; multi-window needs per-window state first. + Window("Code Editor", id: "main-window") { MainWindowView(appState: appState) .frame(minWidth: 720, minHeight: 480) .onReceive( @@ -34,7 +37,7 @@ struct CodeEditorApp: App { // MARK: File CommandGroup(replacing: .newItem) { Button("New File") { - appState.newFile() + appState.requestCreation(kind: .file) } .keyboardShortcut("n", modifiers: .command) @@ -49,6 +52,22 @@ struct CodeEditorApp: App { .keyboardShortcut("o", modifiers: [.command, .shift]) } + CommandGroup(after: .textEditing) { + Button("Find…") { + appState.editorState.isFindVisible = true + } + .keyboardShortcut("f", modifiers: .command) + .disabled(appState.documentManager.activeDocument == nil) + } + + CommandGroup(after: .toolbar) { + Button("Quick Open…") { + appState.toggleQuickOpen() + } + .keyboardShortcut("p", modifiers: .command) + .disabled(appState.fileSystemManager.rootURL == nil) + } + CommandGroup(replacing: .saveItem) { Button("Save") { appState.saveActiveDocument() @@ -102,6 +121,27 @@ struct CodeEditorApp: App { set: { appState.editorState.setWrapLines($0) } )) + Toggle( + "Invisible Characters", + isOn: Binding( + get: { appState.editorState.showInvisibles }, + set: { appState.editorState.setShowInvisibles($0) } + )) + + Toggle( + "Minimap", + isOn: Binding( + get: { appState.editorState.showMinimap }, + set: { appState.editorState.setShowMinimap($0) } + )) + + Toggle( + "Code Folding Ribbon", + isOn: Binding( + get: { appState.editorState.showFoldingRibbon }, + set: { appState.editorState.setShowFoldingRibbon($0) } + )) + Menu("Tab Width") { ForEach([2, 4, 8], id: \.self) { width in Button("\(width) Spaces") { diff --git a/Sources/CodeEditorApp/Sources/MainWindowView.swift b/Sources/CodeEditorApp/Sources/MainWindowView.swift index 78abd4a..8ae5734 100644 --- a/Sources/CodeEditorApp/Sources/MainWindowView.swift +++ b/Sources/CodeEditorApp/Sources/MainWindowView.swift @@ -7,6 +7,9 @@ import SwiftUI struct MainWindowView: View { @Bindable var appState: AppState + /// Terminal height when the current drag began, so the gesture is anchored. + @State private var terminalDragStart: Double? + init(appState: AppState) { self.appState = appState } @@ -41,9 +44,9 @@ struct MainWindowView: View { } if appState.isTerminalVisible { - Divider().overlay(appState.theme.chrome.border.color) + terminalDivider TerminalView(terminal: appState.terminal, theme: appState.theme) - .frame(height: 220) + .frame(height: appState.terminalHeight) } } .background(appState.theme.background.color) @@ -53,6 +56,14 @@ struct MainWindowView: View { .background(appState.theme.background.color) .tint(appState.theme.chrome.accent.color) .preferredColorScheme(appState.theme.colorScheme) + .overlay { + if appState.isQuickOpenVisible { + quickOpenOverlay + } + } + .onChange(of: appState.quickOpenQuery) { _, _ in + appState.updateQuickOpenResults() + } .sheet(item: $appState.pendingCreation) { pending in NewItemSheetView(pending: pending) { name in appState.performCreation(named: name) @@ -82,6 +93,67 @@ struct MainWindowView: View { } } + // MARK: - Quick open + + /// The palette floats near the top of the window. The scrim behind it closes + /// on click, matching what Esc does. + private var quickOpenOverlay: some View { + ZStack(alignment: .top) { + Color.black + .opacity(0.15) + .onTapGesture { appState.isQuickOpenVisible = false } + + QuickOpenView( + query: $appState.quickOpenQuery, + matches: appState.quickOpenResults, + theme: appState.theme, + isIndexing: appState.quickOpenIndex.isIndexing, + onOpen: { url in appState.openFile(url) }, + onClose: { appState.isQuickOpenVisible = false } + ) + .padding(.top, Metrics.Space.spacious) + } + } + + // MARK: - Terminal resize + + /// A drag handle, not a passive hairline: the north-south cursor on hover is + /// the affordance, the capsule is the target. + private var terminalDivider: some View { + Rectangle() + .fill(appState.theme.chrome.barBackground.color) + .frame(height: Metrics.Height.divider) + .overlay { + Capsule() + .fill(appState.theme.chrome.border.color) + .frame(width: 56, height: Metrics.Height.dividerIndicator) + } + .contentShape(Rectangle()) + .onHover { hovering in + if hovering { + NSCursor.resizeUpDown.push() + } else { + NSCursor.pop() + } + } + .gesture( + DragGesture(minimumDistance: 1) + .onChanged { value in + if terminalDragStart == nil { + terminalDragStart = appState.terminalHeight + } + guard let start = terminalDragStart else { return } + appState.terminalHeight = min( + max(start - value.translation.height, 120), + 600 + ) + } + .onEnded { _ in + terminalDragStart = nil + } + ) + } + // MARK: - Content @ViewBuilder @@ -122,6 +194,13 @@ struct MainWindowView: View { ToolbarItemGroup(placement: .primaryAction) { createMenu + Button { + appState.toggleQuickOpen() + } label: { + Image(systemName: "magnifyingglass") + } + .help("Quick Open (⌘P)") + Button { appState.toggleTerminal() } label: { diff --git a/Sources/CodeEditorCore/Sources/Session/SessionStore.swift b/Sources/CodeEditorCore/Sources/Session/SessionStore.swift new file mode 100644 index 0000000..1865e42 --- /dev/null +++ b/Sources/CodeEditorCore/Sources/Session/SessionStore.swift @@ -0,0 +1,47 @@ +import Foundation + +// MARK: - SessionSnapshot + +/// What the app restores on launch: the workspace root, which files were open, +/// and which was active. Untitled documents have no path and are deliberately +/// absent — they have no stable identity across launches. +public struct SessionSnapshot: Codable, Equatable, Sendable { + public var rootPath: String? + public var openPaths: [String] + public var activePath: String? + + public init(rootPath: String? = nil, openPaths: [String] = [], activePath: String? = nil) { + self.rootPath = rootPath + self.openPaths = openPaths + self.activePath = activePath + } +} + +// MARK: - SessionStore + +/// Persists the session snapshot in UserDefaults. +/// +/// Paths are stored as plain strings, which is only correct because the app is not +/// sandboxed. Under App Sandbox a path can become stale after relaunch; a sandboxed +/// build would need security-scoped bookmarks here instead. Do not carry this file +/// into a sandboxed target unchanged. +public enum SessionStore { + private static let key = "CodeEditor.session" + + /// Load the snapshot, if one was saved. + public static func load(defaults: UserDefaults = .standard) -> SessionSnapshot? { + guard let data = defaults.data(forKey: key) else { return nil } + return try? JSONDecoder().decode(SessionSnapshot.self, from: data) + } + + /// Persist the snapshot. + public static func save(_ snapshot: SessionSnapshot, defaults: UserDefaults = .standard) { + guard let data = try? JSONEncoder().encode(snapshot) else { return } + defaults.set(data, forKey: key) + } + + /// Drop the snapshot, for a fresh start on next launch. + public static func clear(defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: key) + } +} diff --git a/Sources/CodeEditorUI/AppState.swift b/Sources/CodeEditorUI/AppState.swift index 424bfe1..02d795b 100644 --- a/Sources/CodeEditorUI/AppState.swift +++ b/Sources/CodeEditorUI/AppState.swift @@ -12,6 +12,7 @@ public final class AppState { public var themeManager: ThemeManager public private(set) var editorState: EditorState public private(set) var terminal: TerminalSession + public private(set) var quickOpenIndex: QuickOpenIndex // Chrome public var isSidebarVisible = true @@ -28,7 +29,24 @@ public final class AppState { /// regardless of whether the tab strip or the menu asked. public var pendingClose: CodeEditorCore.Document? - public init() { + // Quick open + public var isQuickOpenVisible = false + public var quickOpenQuery = "" + public private(set) var quickOpenResults: [QuickOpenMatch] = [] + + /// Terminal panel height in points, persisted. Drag-resized, never fixed. + public var terminalHeight: Double { + didSet { + guard oldValue != terminalHeight else { return } + UserDefaults.standard.set(terminalHeight, forKey: Self.terminalHeightKey) + } + } + + private static let terminalHeightKey = "CodeEditor.terminalHeight" + private static let terminalHeightDefault: Double = 220 + private static let terminalHeightRange: ClosedRange = 120...600 + + public init(restoreSession: Bool = true) { let fileSystemManager = FileSystemManager() let themeManager = ThemeManager() let editorState = EditorState() @@ -40,6 +58,18 @@ public final class AppState { self.themeManager = themeManager self.editorState = editorState self.terminal = TerminalSession() + self.quickOpenIndex = QuickOpenIndex() + + let stored = UserDefaults.standard.double(forKey: Self.terminalHeightKey) + self.terminalHeight = + (Self.terminalHeightRange).contains(stored) + ? stored + : Self.terminalHeightDefault + + documentManager.delegate = self + if restoreSession { + restoreFromSession() + } } // MARK: - Theme @@ -65,6 +95,80 @@ public final class AppState { terminal.disconnect() terminal.workingDirectory = url terminal.clear() + quickOpenIndex.reindex(root: url) + persistSession() + } + + // MARK: - Session + + /// What a relaunch would restore right now. + private var sessionSnapshot: SessionSnapshot { + SessionSnapshot( + rootPath: fileSystemManager.rootURL?.standardizedFileURL.path, + openPaths: documentManager.documents.compactMap { $0.url?.standardizedFileURL.path }, + activePath: documentManager.activeDocument?.url?.standardizedFileURL.path + ) + } + + /// Persist the session whenever the set of things worth restoring changes. + private func persistSession() { + SessionStore.save(sessionSnapshot) + } + + /// Reopen the workspace and tabs from the last session, if any. Files that no + /// longer exist are skipped silently; a missing root clears the snapshot. + private func restoreFromSession() { + guard let snapshot = SessionStore.load() else { return } + + if let rootPath = snapshot.rootPath { + let root = URL(fileURLWithPath: rootPath, isDirectory: true) + if FileManager.default.fileExists(atPath: rootPath) { + fileSystemManager.setRoot(root) + quickOpenIndex.reindex(root: root) + terminal.workingDirectory = root + } else { + SessionStore.clear() + } + } + + guard !snapshot.openPaths.isEmpty else { return } + Task { [weak self] in + var restored: [CodeEditorCore.Document] = [] + for path in snapshot.openPaths { + guard let self, FileManager.default.fileExists(atPath: path) else { continue } + if let document = try? await self.openFileThrowing(URL(fileURLWithPath: path)) { + restored.append(document) + } + } + if let activePath = snapshot.activePath, + let active = restored.first(where: { $0.url?.standardizedFileURL.path == activePath }) + { + self?.documentManager.activate(active) + } + } + } + + // MARK: - Quick open + + /// Re-run the quick-open search. Call on every keystroke; the matcher is + /// linear in the index and the index caps itself. + public func updateQuickOpenResults() { + guard isQuickOpenVisible else { + quickOpenResults = [] + return + } + quickOpenResults = quickOpenIndex.search(quickOpenQuery, limit: 25) + } + + public func toggleQuickOpen() { + isQuickOpenVisible.toggle() + if isQuickOpenVisible { + quickOpenQuery = "" + if let root = fileSystemManager.rootURL, quickOpenIndex.entries.isEmpty { + quickOpenIndex.reindex(root: root) + } + updateQuickOpenResults() + } } // MARK: - Documents @@ -81,7 +185,9 @@ public final class AppState { @discardableResult public func openFileThrowing(_ url: URL) async throws -> CodeEditorCore.Document { - try await documentManager.open(url) + let document = try await documentManager.open(url) + persistSession() + return document } public func newFile() { @@ -235,3 +341,15 @@ public final class AppState { themeManager.setTheme(named: name) } } + +// MARK: - DocumentManagerDelegate + +extension AppState: DocumentManagerDelegate { + public func didOpenDocument(_ document: CodeEditorCore.Document) { + persistSession() + } + + public func didCloseDocument(_ document: CodeEditorCore.Document) { + persistSession() + } +} diff --git a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift index 40aad8e..2bf81cc 100644 --- a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift +++ b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift @@ -5,8 +5,8 @@ import CodeEditorCore import CodeEditorThemes import SwiftUI -/// The editing surface: a tree-sitter highlighted source editor with its own line number -/// gutter, bracket emphasis and undo stack. +/// The editing surface: a tree-sitter highlighted source editor with its own line +/// number gutter, bracket emphasis and undo stack. public struct CodeEditorView: View { @Bindable var document: CodeEditorCore.Document let theme: Theme @@ -38,6 +38,18 @@ public struct CodeEditorView: View { .task { editorState.syncSelection(of: document) } + // The panel reports its own visibility; that is the authoritative signal, + // so this writes back and the forward sync below becomes a no-op. + .onChange(of: sourceEditorState.findPanelVisible) { _, visible in + if let visible { + editorState.isFindVisible = visible + } + } + // Opening from the ⌘F command: state → panel. The upstream coordinator + // calls showFindPanel() when state disagrees with the panel. + .onChange(of: editorState.isFindVisible) { _, visible in + sourceEditorState.findPanelVisible = visible + } } // MARK: - Configuration @@ -50,16 +62,47 @@ public struct CodeEditorView: View { font: editorState.font, lineHeightMultiple: editorState.lineHeight, wrapLines: editorState.wrapLines, - tabWidth: editorState.tabWidth + useSystemCursor: true, + tabWidth: editorState.tabWidth, + bracketPairEmphasis: .underline(color: theme.secondaryText.nsColor) ), behavior: .init( isEditable: !document.isReadOnly, indentOption: editorState.indentOption, reformatAtColumn: editorState.reformatAtColumn + ), + peripherals: .init( + showGutter: true, + showMinimap: editorState.showMinimap, + showReformattingGuide: false, + showFoldingRibbon: editorState.showFoldingRibbon, + invisibleCharactersConfiguration: invisibles, + warningCharacters: Self.warningCharacters ) ) } + /// Invisibles render spaces as dots and tabs as arrows when enabled. Line + /// endings stay hidden: every line would carry a marker and the noise outweighs + /// the signal outside of line-ending forensics. + private var invisibles: InvisibleCharactersConfiguration { + InvisibleCharactersConfiguration( + showSpaces: editorState.showInvisibles, + showTabs: editorState.showInvisibles, + showLineEndings: false + ) + } + + /// Characters that look like what the user meant to type but are not: curly + /// quotes and the invisible spaces. Drawn as warnings by the editor. + private static let warningCharacters: Set = [ + "\"", "'", "“", "”", "‘", "’", "\u{00A0}", "\u{200B}", + ].reduce(into: Set()) { set, character in + for unit in character.utf16 { + set.insert(unit) + } + } + /// Two-way binding between the editor and the document's content. private var textBinding: Binding { Binding( diff --git a/Sources/CodeEditorUI/Sources/Editor/EditorState.swift b/Sources/CodeEditorUI/Sources/Editor/EditorState.swift index c20c284..207bb0b 100644 --- a/Sources/CodeEditorUI/Sources/Editor/EditorState.swift +++ b/Sources/CodeEditorUI/Sources/Editor/EditorState.swift @@ -11,9 +11,12 @@ public final class EditorState { public var font: NSFont = .monospacedSystemFont(ofSize: 13, weight: .regular) public var tabWidth: Int = 4 public var indentOption: IndentOption = .spaces(count: 4) - public var lineHeight: Double = 1.2 + public var lineHeight: Double = 1.35 public var reformatAtColumn: Int = 80 public var wrapLines: Bool = false + public var showMinimap = false + public var showFoldingRibbon = true + public var showInvisibles = false // Transient state for the active document public private(set) var selectedLines: Set = [] @@ -21,9 +24,16 @@ public final class EditorState { public private(set) var caretColumn = 0 public private(set) var caretOffset = 0 + /// Whether the editor's find panel is being asked to show. Synced both ways by + /// `CodeEditorView`: setting true opens the panel, dismissing the panel clears it. + public var isFindVisible = false + private static let fontSizeKey = "CodeEditor.fontSize" private static let tabWidthKey = "CodeEditor.tabWidth" private static let wrapKey = "CodeEditor.wrapLines" + private static let minimapKey = "CodeEditor.showMinimap" + private static let foldingKey = "CodeEditor.showFoldingRibbon" + private static let invisiblesKey = "CodeEditor.showInvisibles" public init() {} @@ -38,6 +48,15 @@ public final class EditorState { tabWidth = max(1, min(8, defaults.integer(forKey: Self.tabWidthKey))) } wrapLines = defaults.bool(forKey: Self.wrapKey) + if defaults.object(forKey: Self.minimapKey) != nil { + showMinimap = defaults.bool(forKey: Self.minimapKey) + } + if defaults.object(forKey: Self.foldingKey) != nil { + showFoldingRibbon = defaults.bool(forKey: Self.foldingKey) + } + if defaults.object(forKey: Self.invisiblesKey) != nil { + showInvisibles = defaults.bool(forKey: Self.invisiblesKey) + } indentOption = .spaces(count: tabWidth) } @@ -57,6 +76,21 @@ public final class EditorState { defaults.set(wrap, forKey: Self.wrapKey) } + public func setShowMinimap(_ show: Bool, defaults: UserDefaults = .standard) { + showMinimap = show + defaults.set(show, forKey: Self.minimapKey) + } + + public func setShowFoldingRibbon(_ show: Bool, defaults: UserDefaults = .standard) { + showFoldingRibbon = show + defaults.set(show, forKey: Self.foldingKey) + } + + public func setShowInvisibles(_ show: Bool, defaults: UserDefaults = .standard) { + showInvisibles = show + defaults.set(show, forKey: Self.invisiblesKey) + } + // MARK: - Caret tracking /// Recompute the status bar position from a document selection. diff --git a/Sources/CodeEditorUI/Sources/Overlays/QuickOpenView.swift b/Sources/CodeEditorUI/Sources/Overlays/QuickOpenView.swift new file mode 100644 index 0000000..08c7ad5 --- /dev/null +++ b/Sources/CodeEditorUI/Sources/Overlays/QuickOpenView.swift @@ -0,0 +1,214 @@ +import CodeEditorCore +import CodeEditorThemes +import SwiftUI + +/// The ⌘P palette: query field over ranked, highlighted results. +/// +/// Presented as a window-level overlay rather than a sheet so it does not dim the +/// editor behind it and dismisses on Esc without ceremony. Results are plain +/// `QuickOpenMatch` values from `QuickOpenIndex`; this view only renders them. +public struct QuickOpenView: View { + @Binding var query: String + let matches: [QuickOpenMatch] + let theme: Theme + let isIndexing: Bool + let onOpen: (URL) -> Void + let onClose: () -> Void + + @FocusState private var isFieldFocused: Bool + @State private var selectionIndex = 0 + + public init( + query: Binding, + matches: [QuickOpenMatch], + theme: Theme, + isIndexing: Bool, + onOpen: @escaping (URL) -> Void, + onClose: @escaping () -> Void + ) { + self._query = query + self.matches = matches + self.theme = theme + self.isIndexing = isIndexing + self.onOpen = onOpen + self.onClose = onClose + } + + public var body: some View { + VStack(spacing: 0) { + field + + if matches.isEmpty { + empty + } else { + results + } + } + .frame(width: 520) + .background(overlayBackground) + .clipShape(RoundedRectangle(cornerRadius: Metrics.Radius.panel, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Metrics.Radius.panel, style: .continuous) + .strokeBorder(theme.chrome.border.color, lineWidth: 1) + ) + .shadow(color: .black.opacity(0.35), radius: 24, y: 8) + .onAppear { isFieldFocused = true } + } + + // MARK: - Parts + + @ViewBuilder + private var overlayBackground: some View { + if theme.chrome.usesMaterials { + Rectangle().fill(.regularMaterial) + } else { + Rectangle().fill(theme.chrome.overlayBackground.color) + } + } + + private var field: some View { + HStack(spacing: Metrics.Space.regular) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + + TextField("Go to file…", text: $query) + .textFieldStyle(.plain) + .font(.system(size: 15, design: .rounded)) + .focused($isFieldFocused) + .onSubmit(acceptSelected) + .onKeyPress(.downArrow) { + selectionIndex = min(selectionIndex + 1, max(matches.count - 1, 0)) + return .handled + } + .onKeyPress(.upArrow) { + selectionIndex = max(selectionIndex - 1, 0) + return .handled + } + .onExitCommand(perform: onClose) + + if isIndexing { + ProgressView() + .controlSize(.small) + .help("Indexing workspace") + } + } + .padding(.horizontal, Metrics.Space.loose) + .padding(.vertical, Metrics.Space.relaxed) + } + + private var empty: some View { + Text(query.isEmpty ? "Type to search the workspace" : "No matches") + .font(Typography.emptyBody) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, Metrics.Space.spacious) + .padding(.bottom, Metrics.Space.relaxed) + } + + private var results: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(matches.enumerated()), id: \.element.url) { index, match in + row(match, isSelected: index == clampedSelection) + .id(match.url) + .onTapGesture { + selectionIndex = index + open(match) + } + } + } + .padding(Metrics.Space.compact) + .padding(.bottom, Metrics.Space.compact) + } + .frame(maxHeight: 320) + .onChange(of: selectionIndex) { _, newIndex in + guard !matches.isEmpty else { return } + proxy.scrollTo(matches[clampedSelection].url, anchor: .center) + } + } + } + + private var clampedSelection: Int { + min(selectionIndex, max(matches.count - 1, 0)) + } + + private func row(_ match: QuickOpenMatch, isSelected: Bool) -> some View { + HStack(spacing: Metrics.Space.regular) { + Image(systemName: iconName(for: match)) + .font(Typography.sidebarRow) + .foregroundStyle(isSelected ? theme.chrome.accent.color : .secondary) + .frame(width: 16) + + highlighted(match) + .font(Typography.sidebarRow) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 0) + } + .padding(.horizontal, Metrics.Space.regular) + .padding(.vertical, Metrics.Space.compact) + .background( + RoundedRectangle(cornerRadius: Metrics.Radius.row, style: .continuous) + .fill(isSelected ? theme.chrome.rowSelected.color : .clear) + ) + .contentShape(Rectangle()) + } + + private func iconName(for match: QuickOpenMatch) -> String { + let name = match.relativePath.lowercased() + if name.hasSuffix("/") || !name.contains(".") { return "folder" } + return "doc" + } + + /// The path with matched characters emphasised over the dimmed remainder. + /// Built by `Text` concatenation with per-run styles; matched runs use the + /// text colour, unmatched the secondary colour. + private func highlighted(_ match: QuickOpenMatch) -> Text { + let path = match.relativePath + let units = Array(path.utf16) + let dimmed = theme.secondaryText.color + let emphasized = theme.text.color + + var result = Text("") + var cursor = 0 + for range in match.highlightRanges.sorted(by: { $0.lowerBound < $1.lowerBound }) { + guard range.lowerBound >= cursor, range.upperBound <= units.count else { continue } + + if range.lowerBound > cursor { + result = + result + + Text( + String(decoding: units[cursor.. Void + ) -> some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 10, weight: .medium)) + .frame(width: 20, height: 20) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .help("\(label) — \(help)") + .accessibilityLabel(label) } private var inputLine: some View { HStack(spacing: 0) { + Text(terminal.workingDirectory.map { "\($0.lastPathComponent) " } ?? "") + .foregroundStyle(theme.secondaryText.color) Text("$ ") - .foregroundColor(theme.chrome.accent.color) + .foregroundStyle(theme.chrome.accent.color) TextField("", text: $input) .textFieldStyle(.plain) .foregroundColor(theme.text.color) @@ -119,8 +161,8 @@ public struct TerminalView: View { switch type { case .standard: theme.text.color case .errorOutput: theme.semantic.danger.color - case .info: theme.comment.color - case .input: theme.string.color + case .info: theme.secondaryText.color + case .input: theme.chrome.accent.color } } } diff --git a/Tests/CodeEditorTests/SessionStoreTests.swift b/Tests/CodeEditorTests/SessionStoreTests.swift new file mode 100644 index 0000000..25f00de --- /dev/null +++ b/Tests/CodeEditorTests/SessionStoreTests.swift @@ -0,0 +1,55 @@ +import CodeEditorCore +import Foundation +import Testing + +@Suite("SessionStore") +struct SessionStoreTests { + /// A private defaults domain plus a cleanup closure, so the domain outlives + /// the helper that made it. + private func freshDefaults() -> (defaults: UserDefaults, cleanup: () -> Void) { + let name = "SessionStoreTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name) ?? .standard + return (defaults, { defaults.removePersistentDomain(forName: name) }) + } + + @Test("a snapshot round-trips") + func roundTrip() { + let (defaults, cleanup) = freshDefaults() + defer { cleanup() } + + let snapshot = SessionSnapshot( + rootPath: "/tmp/project", + openPaths: ["/tmp/project/a.swift", "/tmp/project/b.swift"], + activePath: "/tmp/project/a.swift" + ) + + SessionStore.save(snapshot, defaults: defaults) + #expect(SessionStore.load(defaults: defaults) == snapshot) + } + + @Test("nothing saved loads as nil") + func emptyLoadsNil() { + let (defaults, cleanup) = freshDefaults() + defer { cleanup() } + #expect(SessionStore.load(defaults: defaults) == nil) + } + + @Test("corrupt data loads as nil rather than crashing") + func corruptDataLoadsNil() { + let (defaults, cleanup) = freshDefaults() + defer { cleanup() } + defaults.set(Data("not json".utf8), forKey: "CodeEditor.session") + + #expect(SessionStore.load(defaults: defaults) == nil) + } + + @Test("clear removes the snapshot") + func clearRemoves() { + let (defaults, cleanup) = freshDefaults() + defer { cleanup() } + SessionStore.save(SessionSnapshot(rootPath: "/tmp/x"), defaults: defaults) + + SessionStore.clear(defaults: defaults) + #expect(SessionStore.load(defaults: defaults) == nil) + } +} From 53949d1cbf28d12371e6063b1c45d23741958cce Mon Sep 17 00:00:00 2001 From: yan Date: Fri, 9 Oct 2026 21:25:32 +0300 Subject: [PATCH 11/22] fix(ui): click a folder to expand it, arrow past it without toggling Selecting a folder row now expands it, the Xcode rule; the disclosure triangle stops being the only way in. Activation moved off the selection change onto an explicit row tap, because driving expansion from selection meant arrowing down the tree toggled every folder on the way. Arrow keys now collapse and expand the selected folder and Return opens files, the outline-view convention. --- .../Sources/Sidebar/FileExplorerView.swift | 47 +++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift b/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift index a450bd8..80ae138 100644 --- a/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift +++ b/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift @@ -46,10 +46,6 @@ public struct FileExplorerView: View { emptyState } } - .onChange(of: selection) { _, newValue in - guard let item = newValue, !item.isDirectory else { return } - onOpenFile(item.url) - } .task(id: activeDocumentURL) { await revealActiveDocument() } @@ -105,6 +101,48 @@ public struct FileExplorerView: View { } } .listStyle(.sidebar) + .onKeyPress(.return) { + activateSelection() + return .handled + } + .onKeyPress(.leftArrow) { + collapseSelection() + return .handled + } + .onKeyPress(.rightArrow) { + expandSelection() + return .handled + } + } + + /// What a click, Return or Enter does: files open, folders toggle. Kept out of + /// the selection `onChange` so that arrowing through the tree does not toggle + /// every folder on the way past. + private func activate(_ item: FileSystemItem) { + if item.isDirectory { + fileSystemManager.toggleExpansion(of: item) + } else { + onOpenFile(item.url) + } + } + + private func activateSelection() { + guard let item = selection else { return } + activate(item) + } + + private func collapseSelection() { + guard let item = selection, item.isDirectory, fileSystemManager.isExpanded(item.url) else { + return + } + fileSystemManager.toggleExpansion(of: item) + } + + private func expandSelection() { + guard let item = selection, item.isDirectory, !fileSystemManager.isExpanded(item.url) else { + return + } + fileSystemManager.toggleExpansion(of: item) } private func rowView(_ item: FileSystemItem) -> some View { @@ -128,6 +166,7 @@ public struct FileExplorerView: View { } .padding(.vertical, 2) .contentShape(Rectangle()) + .onTapGesture { activate(item) } .contextMenu { if item.isDirectory { Button { From 963b208480705d6f6545d40b3d474b08c3d6cb02 Mon Sep 17 00:00:00 2001 From: yan Date: Fri, 9 Oct 2026 21:35:47 +0300 Subject: [PATCH 12/22] fix(ui): drive tree activation from selection, not a row tap gesture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row tap gesture inside a selection List is swallowed by the List's own gesture handling: clicking a folder selected it without expanding anything. Activation now hangs off the selection change, which is what List actually delivers — and which is the Xcode navigator's behaviour anyway, since it also expands folders as you arrow through them. The disclosure chevron becomes decorative. With the whole row activating, a second tap target would toggle expansion twice. Verified in the running app: arrowing onto a folder expands it. --- .../Sources/Sidebar/FileExplorerView.swift | 63 ++++++------------- 1 file changed, 19 insertions(+), 44 deletions(-) diff --git a/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift b/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift index 80ae138..2ccf41a 100644 --- a/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift +++ b/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift @@ -101,23 +101,23 @@ public struct FileExplorerView: View { } } .listStyle(.sidebar) - .onKeyPress(.return) { - activateSelection() - return .handled - } - .onKeyPress(.leftArrow) { - collapseSelection() - return .handled + .onChange(of: selection) { _, newValue in + // Selection drives activation, as it does in Xcode's navigator: + // landing on a file opens it, landing on a folder expands it. Arrowing + // through the tree does the same, which is why the expansion lives + // here rather than on a tap gesture that `List` would swallow. + guard let newValue else { return } + activate(newValue) } - .onKeyPress(.rightArrow) { - expandSelection() + .onKeyPress(.return) { + // Return on the already-selected row changes nothing, so this is the + // only way to re-activate the current row. + if let selection { activate(selection) } return .handled } } - /// What a click, Return or Enter does: files open, folders toggle. Kept out of - /// the selection `onChange` so that arrowing through the tree does not toggle - /// every folder on the way past. + /// What selecting a row does: files open, folders toggle. private func activate(_ item: FileSystemItem) { if item.isDirectory { fileSystemManager.toggleExpansion(of: item) @@ -126,25 +126,6 @@ public struct FileExplorerView: View { } } - private func activateSelection() { - guard let item = selection else { return } - activate(item) - } - - private func collapseSelection() { - guard let item = selection, item.isDirectory, fileSystemManager.isExpanded(item.url) else { - return - } - fileSystemManager.toggleExpansion(of: item) - } - - private func expandSelection() { - guard let item = selection, item.isDirectory, !fileSystemManager.isExpanded(item.url) else { - return - } - fileSystemManager.toggleExpansion(of: item) - } - private func rowView(_ item: FileSystemItem) -> some View { let isActiveDocument = item.url == activeDocumentURL @@ -166,7 +147,6 @@ public struct FileExplorerView: View { } .padding(.vertical, 2) .contentShape(Rectangle()) - .onTapGesture { activate(item) } .contextMenu { if item.isDirectory { Button { @@ -207,21 +187,16 @@ public struct FileExplorerView: View { } } - /// The disclosure triangle for folders, a spacer of the same width for files, - /// so names align whether or not their parent expands. + /// The disclosure chevron. Decorative, not a control: selecting the row is what + /// expands a folder, so a second tap target here would toggle it twice. @ViewBuilder private func disclosureControl(for item: FileSystemItem) -> some View { if item.isDirectory { - Button { - fileSystemManager.toggleExpansion(of: item) - } label: { - Image(systemName: fileSystemManager.isExpanded(item.url) ? "chevron.down" : "chevron.right") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(.tertiary) - .frame(width: 12, height: 12) - } - .buttonStyle(.plain) - .help(fileSystemManager.isExpanded(item.url) ? "Collapse" : "Expand") + Image(systemName: fileSystemManager.isExpanded(item.url) ? "chevron.down" : "chevron.right") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.tertiary) + .frame(width: 12, height: 12) + .accessibilityHidden(true) } else { Color.clear.frame(width: 12, height: 12) } From 7ac3f76e7811c402002c992d94752b0c118ddb7b Mon Sep 17 00:00:00 2001 From: yan Date: Fri, 9 Oct 2026 21:39:44 +0300 Subject: [PATCH 13/22] docs: record the redesigned interface and its invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CURRENT_STATE gains a description of what the window is now, and states the four honest limitations: the quick-open index is a snapshot rather than an FSEvents stream, session restore stores plain paths and is only valid unsandboxed, untitled documents do not restore, and the app is single-window. Test counts and module sizes re-measured against the tree. MEMORY.md gains five traps this work cost: never derive appearance from colour arithmetic (#11), a tap gesture inside a selection List is swallowed so row behaviour must hang off selection (#12), a split-view column is sized in exactly one place (#13), SessionStore's plain paths are only valid unsandboxed (#14), and .prominent is iOS (#15). AGENTS.md gains the two design rules that are now conventions: chrome never borrows a syntax token, and no magic numbers in chrome. README documents ⌘F, ⌘P, session restore and where the tokens live. ARCHITECTURE records the design-system split between Themes (colours, type) and Core (metrics), so Core keeps its Foundation-only rule. --- AGENTS.md | 12 ++++++ README.md | 25 ++++++++++-- docs/ARCHITECTURE.md | 43 ++++++++++++++++++--- docs/CURRENT_STATE.md | 88 ++++++++++++++++++++++++++++++------------- docs/IDEA.md | 33 +++++++++------- docs/MEMORY.md | 79 +++++++++++++++++++++++++++++++++++++- 6 files changed, 229 insertions(+), 51 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 185a28e..2f1260d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,13 @@ Never imply in a commit message or PR that CI verified the build. It did not. - **4-space indent, 120 columns.** Enforced by `.swift-format`. - **`CodeEditorCore` imports `Foundation` only** — no SwiftUI, no AppKit. That is what makes it testable without a window server, and what makes offset arithmetic testable at all. +- **Chrome never borrows a syntax token.** Colours come from `Theme.chrome` or + `Theme.semantic`. The accent was once `Theme.keyword`, which made the tab underline, folder + icons, the terminal prompt and terminal errors change meaning with the syntax palette. +- **No magic numbers in chrome.** Spacing, radii and fixed heights come from `Metrics` + (`CodeEditorCore`); the 4pt grid is enforced by a test. Chrome type comes from + `Typography` — semantic styles only, so it scales with the user's text size. Code keeps a + fixed-size SF Mono. See [MEMORY.md](docs/MEMORY.md#11) for why these rules exist. ## Traps that will bite @@ -98,6 +105,10 @@ Never imply in a commit message or PR that CI verified the build. It did not. - Do not give a `@MainActor` class a `deinit` that touches its state. - `SourceEditor` takes a `Binding`; there is no push path from `Document` to the view. See [MEMORY.md #4](docs/MEMORY.md). +- A `.onTapGesture` on a row inside a `List(selection:)` never fires. Drive row behaviour + from the selection change. See [MEMORY.md #12](docs/MEMORY.md#12-a-tap-gesture-inside-a-selection-list-is-swallowed). +- Do not put a `.frame` on a `NavigationSplitView` column; `.navigationSplitViewColumnWidth` + already sizes it. See [MEMORY.md #13](docs/MEMORY.md). ## Before you call it done @@ -106,6 +117,7 @@ Never imply in a commit message or PR that CI verified the build. It did not. - [ ] `swift-format lint --strict` clean - [ ] Behaviour change has a test; bug fix has a test that fails without the fix - [ ] New public APIs have doc comments that state the contract +- [ ] View code with real logic moved into `CodeEditorCore` and tested there - [ ] The `.md` files above are still true That last item is not a formality. It is the difference between this repository and one diff --git a/README.md b/README.md index d350f6b..99d779f 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,28 @@ an embedded shell in the same window. - **Tabs** — open documents, dirty-state dots, close with a save prompt. - **Save / Save All**, with a location prompt for untitled documents. Line endings are detected on load and preserved on save. +- **Find and replace** — ⌘F, backed by the source editor's own panel. +- **Quick open** — ⌘P, fuzzy-matching files across the workspace with the matched + characters highlighted. - **Embedded terminal** — runs your `$SHELL`, with scrollback capped at 2000 lines, - command history on ↑/↓, and separate stdout/stderr coloring. + command history on ↑/↓, selectable output, and separate stdout/stderr coloring. Drag + the divider above it to resize; the height is remembered. - **Themes** — Dark, Light and High Contrast, persisted across launches along with font - size, tab width and word wrap. -- **Menu commands** — ⌘N, ⌘O, ⇧⌘O, ⌘S, ⌥⌘S, ⌘W, ⌘B, ⌃`, ⇧⌘L for theme. + size, tab width, word wrap, minimap, folding ribbon and invisibles. +- **Session restore** — reopens your workspace and tabs on launch. Untitled documents are + not restored. +- **Menu commands** — ⌘N, ⌘O, ⇧⌘O, ⌘F, ⌘P, ⌘S, ⌥⌘S, ⌘W, ⌘B, ⌃`, ⇧⌘L for theme. + +### Interface notes + +The window uses native macOS chrome: a unified toolbar with a breadcrumb, a navigator that +opens files and expands folders on click or arrow key, a tab strip whose active tab merges +into the editor, and a single empty state that adapts to whether a workspace is open. + +Design tokens are not sprinkled through the views. Colours live in `Theme.chrome` and +`Theme.semantic` — chrome never borrows a syntax token. Spacing, radii and chrome heights +live in `Metrics` in `CodeEditorCore`, on a 4pt grid. Chrome type is semantic, so it scales +with your system text size; the code font stays fixed-size on purpose. ## Requirements @@ -118,6 +135,8 @@ leaves that `UI` composes. | `⌘N` | New file | | `⌘O` | Open folder | | `⇧⌘O` | Open files | +| `⌘F` | Find | +| `⌘P` | Quick open | | `⌘S` / `⌥⌘S` | Save / Save all | | `⌘W` | Close tab | | `⌘B` | Toggle sidebar | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ffcb756..3ba4d15 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,12 +16,31 @@ them. `App` is the only target that owns a window. | Module | Lines | Owns | |---|---|---| -| `CodeEditorCore` | 958 | `Document`, `DocumentManager`, `TextSelectionManager`, `FileSystemManager`, `Language`, `LineEnding`, `TextSelection` | +| `CodeEditorCore` | 1437 | `Document`, `DocumentManager`, `TextSelectionManager`, `FileSystemManager`, `FileOutline`, `QuickOpenMatcher`, `QuickOpenIndex`, `SessionStore`, `Metrics`, `Language`, `LineEnding`, `TextSelection` | | `CodeEditorLSP` | 1143 | `JSONRPCTransport`, `LSPClient`, wire types | -| `CodeEditorUI` | 966 | `CodeEditorView`, `FileExplorerView`, `TerminalView`, `EditorState`, `AppState`, `Theme.editorTheme` | -| `CodeEditorApp` | 370 | `MainWindowView`, `CodeEditorApp`, menu commands | +| `CodeEditorUI` | 1843 | `CodeEditorView`, `FileExplorerView`, `TabBarView`, `StatusBarView`, `EmptyStateView`, `QuickOpenView`, `TerminalView`, `EditorState`, `AppState`, `Theme.editorTheme` | +| `CodeEditorApp` | 484 | `MainWindowView`, `CodeEditorApp`, menu commands | | `CodeEditorTerminal` | 245 | `TerminalSession` | -| `CodeEditorThemes` | 225 | `Theme`, `EditorColor`, `ThemeManager` | +| `CodeEditorThemes` | 437 | `Theme`, `Chrome`, `Semantic`, `Typography`, `EditorColor`, `ThemeManager` | + +## Where the design system lives + +Two modules, split by what they may import: + +- **`CodeEditorThemes`** — colours and type. `Theme` holds the canvas and syntax tokens, a + `Chrome` group for window furniture, a `Semantic` group for status colours, and the + `colorScheme` the window forces. `Typography` maps roles to semantic styles. +- **`CodeEditorCore`** — `Metrics`, the spacing scale, radii and fixed chrome heights. It is + pure layout arithmetic, so it stays in the Foundation-only module and its invariants are + testable without a window server. + +The rule that matters: **chrome never borrows a syntax token.** The accent used to be +`Theme.keyword`, which meant the tab underline, folder icons, the terminal prompt and +terminal errors all changed meaning whenever the syntax palette did. + +Views must not introduce a literal font size or a padding off the 4pt grid. `MetricsTests` +fails if the grid is broken; nothing enforces the font rule automatically, so `.system(size:)` +outside `CodeEditorThemes` is a review item. ## The rule that matters @@ -124,8 +143,15 @@ others, and the union adds decoding branches nothing exercises. ## Tests -`CodeEditorCore` and `CodeEditorTerminal` are tested; `CodeEditorUI` and `CodeEditorApp` are -not, because they are view code. +`CodeEditorCore`, `CodeEditorThemes` and `CodeEditorTerminal` are tested; `CodeEditorUI` and +`CodeEditorApp` are not, because they are view code. + +The rule when view code grows real logic: **move the logic to `CodeEditorCore` and test it +there.** The sidebar's tree flattening became `FileOutline`; quick open became +`QuickOpenMatcher` plus `QuickOpenIndex`. Neither had to change behaviour to be tested. + +`UserDefaults`-backed state takes its domain from the caller rather than reaching for +`.standard`, which is why `SessionStore` and `ThemeManager` are testable. The useful pattern is `StubProvider`: an in-memory `FileSystemProvider`, so file-system tests assert on URLs and names and never touch the real disk or depend on a fixture directory. @@ -135,6 +161,11 @@ Anything that grows a filesystem dependency should use it. - `SourceEditor` and `EditorTheme.Attribute` — contained in `CodeEditorView.swift` and `EditorTheme+Bridge.swift`. Both are upstream, both are pre-1.0. See [STACK.md](STACK.md). +- `SourceEditorState.findPanelVisible` — `EditorState.isFindVisible` is our flag, + `CodeEditorView` syncs it both ways, and the upstream coordinator opens the panel when + state disagrees with it. The panel stays the authority on its own visibility. +- `TreeSitterClient.Constants.longParse` — a notification name from the upstream module. + The editor surfaces it as a parse indicator; nothing else depends on it. - `Notification.Name.codeEditorOpenFolder` / `codeEditorOpenFiles` — `UI` posts, `App` observes. Replace with a closure when a second consumer appears. - `FileSystemManager.load` returns rows synchronously from a cache while populating it diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 766ebdb..728e8f1 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -1,40 +1,62 @@ # Current state -Last verified against `main` on 2026-10-09, Swift 6.4, macOS 27.0.1. +Last verified against `feature/ui-redesign` on 2026-10-09, Swift 6.4, macOS 27.0.1. ``` swift build → Build complete -swift test → 35 tests in 10 suites passed +swift test → 66 tests in 16 suites passed swift-format lint --strict → 0 warnings ``` -Tagged **0.1.0**. +Tagged **0.1.0**. The interface has been redesigned since that tag; see +[Redesigned interface](#redesigned-interface). + +## Redesigned interface + +The window was rebuilt around native macOS chrome. The design tokens live in +`CodeEditorThemes`, the metrics scale in `CodeEditorCore`. + +| Area | What it does now | +|---|---| +| **Theme** | `Theme` carries `colorScheme`, a `Chrome` group (accent, sidebar, rows, bars, panels, overlays, borders) and a `Semantic` group (danger, success, warning). Three palettes: Dark, Light, High Contrast. High Contrast sets `usesMaterials = false`. | +| **Typography** | `Typography` maps roles to semantic styles, so chrome scales with the system text size. `.system(size:)` is banned from chrome. The canvas stays a fixed-size SF Mono — code reflowing mid-edit is worse than the accessibility it buys. | +| **Metrics** | One 4pt spacing scale, radii keyed by surface, fixed chrome heights. Enforced by `MetricsTests`. | +| **Toolbar** | Unified: breadcrumb principal, create menu and quick-open/terminal toggles trailing. The sidebar's hand-rolled header is gone. | +| **Sidebar** | Click or arrow to a row to activate: files open, folders expand. The active document is revealed by expanding its ancestors, shown bold with an accent icon. Delete asks first. | +| **Tabs** | 32pt strip, active tab filled with the canvas colour and rounded so it merges with the editor. Dirty dot beside the title; close button always present. | +| **Status bar** | Caret, selection count, UTF-8, line ending, language. The byte count is gone. | +| **Empty state** | One, in the canvas. With a workspace open it points at the sidebar; without one it offers Open Folder as the single prominent action. | +| **Find/replace** | ⌘F opens CodeEdit's own panel through `SourceEditorState.findPanelVisible`. | +| **Quick open** | ⌘P over a bounded filesystem index. Matched characters are emphasised. | +| **Terminal** | Drag-resized 120–600pt, persisted. Semantic status dot, icon buttons, selectable output. | +| **Session** | Workspace root, open tabs and active tab restore on launch. | ## Working **File explorer.** Recursive tree, lazily loaded — only expanded branches are enumerated. Create file, create folder, rename, delete, reveal in Finder, copy path. Dotfiles hidden. -Double-click a file to open it; single-click a folder to set the create-target. +New items land beside the active document, or at the workspace root. **Editing.** `SourceEditor` from CodeEditSourceEditor 0.15, bound two-way to `Document.content`. Tree-sitter highlighting across roughly 25 languages, line-number gutter, bracket-pair emphasis, indentation and reformat-at-column, word wrap, undo stack -shared with the document model. +shared with the document model. Peripherals are configured explicitly: gutter on, minimap +off by default with a toggle, folding ribbon and invisibles toggleable, smart quotes and +invisible spaces flagged as warnings. **Documents.** Tabs with dirty-state dots. Save, Save All, close with a save prompt. Untitled documents get a location prompt. Line endings detected on load, preserved on save. -UTF-8 byte and line counts in the status bar. **Terminal.** Runs `$SHELL -i` in a subprocess. Line-buffered scrollback capped at 2000 lines, command history on ↑/↓, stdout and stderr colored separately. **Themes.** Dark, Light, High Contrast. Selection persists to `UserDefaults`, along with -font size, tab width and word wrap. +font size, tab width, word wrap, minimap, folding ribbon and invisibles. -**Commands.** ⌘N, ⌘O, ⇧⌘O, ⌘S, ⌥⌘S, ⌘W, ⌘B, ⌃`, ⇧⌘L. All wired; none are empty closures. +**Commands.** ⌘N, ⌘O, ⇧⌘O, ⌘F, ⌘P, ⌘S, ⌥⌘S, ⌘W, ⌘B, ⌃`, ⇧⌘L. All wired; none are empty closures. -**Tests.** 35 swift-testing cases. `FileSystemManager` is tested against an in-memory -provider, so no test touches the real disk. +**Tests.** 66 swift-testing cases. `FileSystemManager` and `QuickOpenIndex` are tested +against in-memory providers, so no test touches the real disk. ## Not working @@ -50,11 +72,21 @@ go-to-definition are [#1](https://github.com/Yan123-tech/code-editor/issues/1) **No external-change detection.** If a file changes on disk while open, the editor does not notice and will overwrite it. [#6](https://github.com/Yan123-tech/code-editor/issues/6). +**The quick-open index is a snapshot, not a stream.** It is a filesystem walk refreshed when +the workspace root changes. Files created afterwards are not findable until the next +reindex. Not FSEvents. + +**Session restore stores plain paths.** Correct only because the app is not sandboxed. +Under App Sandbox a path can go stale between launches and this would need +security-scoped bookmarks. See [MEMORY.md](MEMORY.md). + +**Untitled documents are not restored.** They have no stable identity across launches. + **No multi-cursor.** `TextSelectionManager` models multiple selections and `Document` stores them, but the text view drives a single caret. -**No find/replace panel.** `SourceEditorState` has the fields for it -(`findText`, `replaceText`, `findPanelVisible`); nothing surfaces them. +**Single window only.** `Window` replaced `WindowGroup`; a second window would have shared +one `AppState`, one document set and one sidebar. Multi-window needs per-window state first. **CI does not build.** Format and syntax only. See [MEMORY.md](MEMORY.md). @@ -65,21 +97,25 @@ them, but the text view drives a single caret. | LineEnding | 1 | detection precedence | | Language | 1 | extension → language | | TextSelection | 2 | inverted ranges, NSRange | -| Document | 9 | line splitting, offset math, selection clamping, insert/delete, line endings, line ranges | +| Document | 10 | line splitting, offset math, selection clamping, insert/delete, line endings, line ranges | | Document persistence | 2 | save/reload round-trip, `noURL` | | DocumentManager | 3 | reuse, neighbour focus, dirty tracking | | TextSelectionManager | 4 | primary selection, joined text | -| FileSystemManager | 6 | root validation, sort order, create, rename, expansion, cache keys | +| FileSystemManager | 10 | root validation, sort order, create, rename, expansion, cache keys, `reveal`, `clearError` | | FileSystemItem | 2 | hidden files, icons | +| Metrics | 5 | spacing grid, radii ordering, chrome heights | +| ThemeManager | 4 | appearance per palette, persistence round-trip, unknown name | +| SessionStore | 4 | round-trip, empty, corrupt data, clear | +| FileOutline | 3 | expansion and depth, collapsed branch, depth cap | +| QuickOpenMatcher | 7 | subsequence, ranking, highlight ranges, folding | +| QuickOpenIndex | 4 | relative paths, skipped directories, ranking, empty query | | TerminalSession | 4 | history, failed connect, clear | -Untested: `CodeEditorUI` and `CodeEditorApp` entirely (they are view code), the LSP -transport, and `ThemeManager` persistence. +Untested: the view layer in `CodeEditorUI` and `CodeEditorApp` beyond the pure logic +extracted into `CodeEditorCore`, and the LSP transport. ## Next -In order, by issue: - 1. [#1](https://github.com/Yan123-tech/code-editor/issues/1) — connect `LSPClient` via a `TextViewCoordinator`; completion popup 2. [#2](https://github.com/Yan123-tech/code-editor/issues/2) — diagnostics overlay and gutter markers 3. [#3](https://github.com/Yan123-tech/code-editor/issues/3) — hover and go-to-definition @@ -94,14 +130,14 @@ as a placeholder branch — plan only, no code. | Module | Lines | Files | |---|---|---| -| CodeEditorCore | 958 | 3 | +| CodeEditorCore | 1437 | 8 | | CodeEditorLSP | 1143 | 3 | -| CodeEditorUI | 966 | 6 | -| CodeEditorApp | 370 | 2 | +| CodeEditorUI | 1843 | 11 | +| CodeEditorApp | 484 | 2 | +| CodeEditorThemes | 437 | 5 | | CodeEditorTerminal | 245 | 1 | -| CodeEditorThemes | 225 | 1 | -| Tests | 498 | 2 | +| Tests | 947 | 6 | -LSP is the largest module and the least used. That is deliberate for now — it is the -interesting part — but if the next three issues do not land, it becomes dead weight that -should be deleted rather than kept aspirational. \ No newline at end of file +LSP is the largest module after `CodeEditorUI` and the least used. That is deliberate for +now — it is the interesting part — but if the next three issues do not land, it becomes +dead weight that should be deleted rather than kept aspirational. diff --git a/docs/IDEA.md b/docs/IDEA.md index 83ddc98..e5af664 100644 --- a/docs/IDEA.md +++ b/docs/IDEA.md @@ -33,23 +33,28 @@ without an IDE around it. ## Shape of the thing ``` -┌──────────────────────────────────────────────────┐ -│ ⌘ commands · theme · preferences │ -├────────────┬─────────────────────────────────────┤ -│ │ tabs │ -│ explorer ├─────────────────────────────────────┤ -│ (tree) │ │ -│ │ editor + tree-sitter highlighting │ -│ │ gutter · bracket emphasis · undo │ -│ ├─────────────────────────────────────┤ -│ │ status bar: Ln/Col, encoding │ -├────────────┴─────────────────────────────────────┤ -│ terminal (toggleable) │ -└──────────────────────────────────────────────────┘ +┌────────────────────────────────────────────────────┐ +│ ⌃sidebar breadcrumb create ⌕ terminal │ +├────────────┬───────────────────────────────────────┤ +│ │ tabs (active merges into the canvas) │ +│ explorer ├───────────────────────────────────────┤ +│ (tree) │ │ +│ │ editor + tree-sitter highlighting │ +│ │ gutter · bracket emphasis · undo │ +│ ├───────────────────────────────────────┤ +│ │ status bar: Ln/Col · UTF-8 · language│ +├────────────┴───── drag handle ─────────────────────┤ +│ terminal (toggleable, resizable) │ +└────────────────────────────────────────────────────┘ ``` Three panels, one window, no chrome that is not doing work. The sidebar is resizable, the -terminal hides entirely, and the editor takes whatever is left. +terminal hides entirely and is drag-resized, and the editor takes whatever is left. + +Two invariants hold the interface together. **Chrome never borrows a colour from syntax** — +window furniture reads `Theme.chrome`, status reads `Theme.semantic`. And **the chrome has +one type ramp, one spacing grid and one set of heights**, all in code, none of them inline +literals. ## Principles diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 4f47323..d8dc4e6 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -183,8 +183,8 @@ inexplicably, `rm -rf .build` before debugging anything else. ## 10. History was rebuilt once -The first published history contained commits that captured stale file contents, caused by -a `git reset --hard` mid-task. The published `main` was reconstructed from scratch and +The first published history contained commits that captured stale file contents, caused by a +`git reset --hard` mid-task. The published `main` was reconstructed from scratch and verified green, so what is on the remote is correct — but part of that work was redone. The lesson is procedural and it is in [BRANCHING.md](BRANCHING.md): work on feature branches @@ -193,6 +193,77 @@ that way. --- +## 11. Never derive appearance from colour arithmetic + +`ThemeManager.isDark` used to be `currentTheme.background.red < currentTheme.background.blue`. + +That returns **false for any neutral background** — `#1e1e1e` is `30 < 30` — so the Dark and +High Contrast palettes forced `.preferredColorScheme(.light)` and every system-drawn control +rendered light against a dark canvas. It looked like a styling problem and was a comparison +operator. + +`Theme` now declares `colorScheme` per palette and `isDark` reads it. + +Tests: `ThemeManagerTests.appearanceMatchesPalette`, `.neutralBackgroundsAreNotMisread`. +**If you add a palette, set `colorScheme` explicitly.** The initializer defaults it to +`.dark`, which is a silent trap for a new light palette. + +--- + +## 12. A tap gesture inside a selection `List` is swallowed + +The sidebar's file tree wanted click-a-row-to-activate. With `List(selection:)` the obvious +`.onTapGesture` on the row **never fires** — the List's own gesture handling consumes the +click, so the row only changed selection and nothing activated. + +Activation has to hang off `.onChange(of: selection)`. That is also the Xcode navigator's +behaviour: landing on a file opens it, landing on a folder expands it, and arrowing through +the tree does the same. + +Corollary: if the row also carries a button (a disclosure triangle), the button's action and +the selection change both run, so a click toggles twice. Make such controls decorative, or +drive one path only. + +Verified by arrowing onto a folder in the running app, not by clicking — synthetic clicks +from System Events do not reach SwiftUI rows, so do not trust them as evidence either way. + +--- + +## 13. `NavigationSplitView` columns are sized in one place + +`FileExplorerView` carried `.frame(minWidth:idealWidth:maxWidth:)` on its own root *and* the +split view declared `.navigationSplitViewColumnWidth` on the same column. Two authorities +for one width; the column took whichever won, and the sidebar jumped on relayout. + +`.navigationSplitViewColumnWidth` belongs on the column. Nothing inside the column should +also claim a width. + +--- + +## 14. `SessionStore` stores plain paths, which is only valid unsandboxed + +`SessionStore` persists `rootPath` and tab paths as strings. That is correct only because +the app is not sandboxed. Under App Sandbox a path can point somewhere useless after +relaunch and the app would restore nothing, silently. + +A sandboxed build must store `URL.bookmarkData(includingResourceValuesForKeys:options:)` +and resolve with `startAccessingSecurityScopedResource()`. There is a test at +`SessionStoreTests.roundTrip` that would still pass with bookmarks, so the suite is not the +guard here — this note is. + +--- + +## 15. `.buttonStyle(.prominent)` does not exist on macOS + +It is iOS. On macOS the equivalent is `.borderedProminent`. The compiler's "cannot be +resolved without a contextual type" is a poor hint for this; the real problem is the wrong +platform's spelling. + +Also: `Button` inside a SwiftUI `Menu` label, and `onExitCommand(perform:)`, both need the +explicit label even when the argument is a bare function reference. + +--- + ## Quick reference | Question | Answer | @@ -203,4 +274,8 @@ that way. | Why is the sidebar slow to show subfolders? | `load` is async and re-renders as children arrive. By design. | | Why does `Document` not resolve? | `SwiftUI.Document`. Qualify as `CodeEditorCore.Document`. | | Can I bump CodeEditSourceEditor? | Yes; watch the two files listed in #8. | +| Where do I add a colour? | `Theme.chrome` for chrome, `Theme.semantic` for status. Never a syntax token. | +| Where do I add a spacing value? | `Metrics` in Core. The 4pt grid is enforced by a test. | +| Why did my row tap do nothing? | #12. Drive it from `selection`. | +| Can I derive isDark from colours? | No. #11. | | Where do I write "this does not work"? | Here, and [CURRENT_STATE.md](CURRENT_STATE.md). | \ No newline at end of file From a09d909d10d9254a7e7bd0f9b77eee51a6a946e3 Mon Sep 17 00:00:00 2001 From: yan Date: Fri, 9 Oct 2026 22:38:01 +0300 Subject: [PATCH 14/22] fix(app): make build-app.sh actually build; stop claiming a bare caret selects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-app.sh passed --show-bin-path to both of its build commands, which locates the output rather than producing it. It therefore only worked when something else had already run swift build — and failed on a clean checkout with 'No such file or directory' pointing at a binary that had never been built. Found by running it from a clean state, which is the only way it ever fails. Document.selectedLines(for:) returns nothing for a collapsed selection. EditorState derived selectedLines from start.line...end.line, which for a caret is one line, so the status bar read '1 line selected' on every freshly opened file. The line-count arithmetic moves to Core where it is testable; EditorState now just reads it. --- Scripts/build-app.sh | 2 +- .../Sources/Document/Document.swift | 14 ++++++++++++ .../Sources/Editor/EditorState.swift | 6 +---- Tests/CodeEditorTests/CodeEditorTests.swift | 22 +++++++++++++++++++ 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/Scripts/build-app.sh b/Scripts/build-app.sh index da4f57c..3c607b2 100755 --- a/Scripts/build-app.sh +++ b/Scripts/build-app.sh @@ -39,7 +39,7 @@ EXECUTABLE="CodeEditorApp" cd "$ROOT" echo "==> Building $EXECUTABLE ($CONFIGURATION)" -swift build -c "$CONFIGURATION" --product "$EXECUTABLE" --show-bin-path > /dev/null +swift build -c "$CONFIGURATION" --product "$EXECUTABLE" BIN_PATH="$(swift build -c "$CONFIGURATION" --product "$EXECUTABLE" --show-bin-path)" echo "==> Laying out $DESTINATION" diff --git a/Sources/CodeEditorCore/Sources/Document/Document.swift b/Sources/CodeEditorCore/Sources/Document/Document.swift index 40976cb..ec72a00 100644 --- a/Sources/CodeEditorCore/Sources/Document/Document.swift +++ b/Sources/CodeEditorCore/Sources/Document/Document.swift @@ -302,6 +302,20 @@ public final class Document: Identifiable { return NSRange(location: start, length: end - start) } + /// The 0-based lines `selection` covers. + /// + /// Empty when the selection is a collapsed caret. A caret does sit *on* a line, + /// but counting it made the status bar claim "1 line selected" for a document with + /// nothing selected — which is every freshly opened file. A caller that wants the + /// caret's line uses `lineAndColumn(for:)`. + public func selectedLines(for selection: TextSelection) -> Set { + guard !selection.isEmpty else { return [] } + guard let start = lineAndColumn(for: selection.start), + let end = lineAndColumn(for: selection.end) + else { return [] } + return Set(start.line...end.line) + } + /// The substring covered by the primary selection. public var selectedText: String { let range = nsRange(from: selection.start, to: selection.end) ?? NSRange(location: 0, length: 0) diff --git a/Sources/CodeEditorUI/Sources/Editor/EditorState.swift b/Sources/CodeEditorUI/Sources/Editor/EditorState.swift index 207bb0b..9ba3bf7 100644 --- a/Sources/CodeEditorUI/Sources/Editor/EditorState.swift +++ b/Sources/CodeEditorUI/Sources/Editor/EditorState.swift @@ -113,11 +113,7 @@ public final class EditorState { caretOffset = selection.start } - if let start, let end { - selectedLines = Set(start.line...end.line) - } else { - selectedLines = [] - } + selectedLines = document.selectedLines(for: selection) } public func setCaret(line: Int, column: Int) { diff --git a/Tests/CodeEditorTests/CodeEditorTests.swift b/Tests/CodeEditorTests/CodeEditorTests.swift index f6f7bc0..af7cad1 100644 --- a/Tests/CodeEditorTests/CodeEditorTests.swift +++ b/Tests/CodeEditorTests/CodeEditorTests.swift @@ -79,6 +79,28 @@ struct DocumentTests { #expect(document.offset(forLine: 9, column: 0) == nil) } + @Test("a collapsed caret selects no lines") + func collapsedCaretSelectsNothing() { + let document = Document(content: "abc\ndef\nghi") + + // Regression: the status bar reported "1 line selected" for every freshly + // opened file, because the caret's line counted as a selection. + #expect(document.selectedLines(for: TextSelection(location: 2)) == []) + #expect(document.selectedLines(for: TextSelection(location: 0)) == []) + #expect(document.selectedLines(for: TextSelection()) == []) + } + + @Test("a selection reports every line it covers") + func selectionReportsCoveredLines() { + let document = Document(content: "abc\ndef\nghi\njkl") + + // From the start of line 0 to the start of line 2 covers lines 0, 1 and 2. + #expect(document.selectedLines(for: TextSelection(start: 0, end: 8)) == [0, 1, 2]) + + // Within one line is still one selected line. + #expect(document.selectedLines(for: TextSelection(start: 4, end: 6)) == [1]) + } + @Test("clamps selections to the content bounds") func selectionClamping() { let document = Document(content: "hello") From eea140c5d297e4dd4e73e84b7a7779830c0990eb Mon Sep 17 00:00:00 2001 From: yan Date: Fri, 9 Oct 2026 22:39:16 +0300 Subject: [PATCH 15/22] docs: record the build-script and selection-summary traps MEMORY.md gains #16 (a build step that passes while doing nothing, and how to actually exercise it) and #17 (a caret is not a selection, and a user-facing summary is a claim about state rather than about a range). Test counts and module sizes re-measured: 68 tests, Core 1451 lines. --- docs/ARCHITECTURE.md | 4 ++-- docs/CURRENT_STATE.md | 12 ++++++------ docs/MEMORY.md | 45 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3ba4d15..b8f87d1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,9 +16,9 @@ them. `App` is the only target that owns a window. | Module | Lines | Owns | |---|---|---| -| `CodeEditorCore` | 1437 | `Document`, `DocumentManager`, `TextSelectionManager`, `FileSystemManager`, `FileOutline`, `QuickOpenMatcher`, `QuickOpenIndex`, `SessionStore`, `Metrics`, `Language`, `LineEnding`, `TextSelection` | +| `CodeEditorCore` | 1451 | `Document`, `DocumentManager`, `TextSelectionManager`, `FileSystemManager`, `FileOutline`, `QuickOpenMatcher`, `QuickOpenIndex`, `SessionStore`, `Metrics`, `Language`, `LineEnding`, `TextSelection` | | `CodeEditorLSP` | 1143 | `JSONRPCTransport`, `LSPClient`, wire types | -| `CodeEditorUI` | 1843 | `CodeEditorView`, `FileExplorerView`, `TabBarView`, `StatusBarView`, `EmptyStateView`, `QuickOpenView`, `TerminalView`, `EditorState`, `AppState`, `Theme.editorTheme` | +| `CodeEditorUI` | 1839 | `CodeEditorView`, `FileExplorerView`, `TabBarView`, `StatusBarView`, `EmptyStateView`, `QuickOpenView`, `TerminalView`, `EditorState`, `AppState`, `Theme.editorTheme` | | `CodeEditorApp` | 484 | `MainWindowView`, `CodeEditorApp`, menu commands | | `CodeEditorTerminal` | 245 | `TerminalSession` | | `CodeEditorThemes` | 437 | `Theme`, `Chrome`, `Semantic`, `Typography`, `EditorColor`, `ThemeManager` | diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 728e8f1..7a2fc93 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -4,7 +4,7 @@ Last verified against `feature/ui-redesign` on 2026-10-09, Swift 6.4, macOS 27.0 ``` swift build → Build complete -swift test → 66 tests in 16 suites passed +swift test → 68 tests in 16 suites passed swift-format lint --strict → 0 warnings ``` @@ -55,7 +55,7 @@ font size, tab width, word wrap, minimap, folding ribbon and invisibles. **Commands.** ⌘N, ⌘O, ⇧⌘O, ⌘F, ⌘P, ⌘S, ⌥⌘S, ⌘W, ⌘B, ⌃`, ⇧⌘L. All wired; none are empty closures. -**Tests.** 66 swift-testing cases. `FileSystemManager` and `QuickOpenIndex` are tested +**Tests.** 68 swift-testing cases. `FileSystemManager` and `QuickOpenIndex` are tested against in-memory providers, so no test touches the real disk. ## Not working @@ -97,7 +97,7 @@ one `AppState`, one document set and one sidebar. Multi-window needs per-window | LineEnding | 1 | detection precedence | | Language | 1 | extension → language | | TextSelection | 2 | inverted ranges, NSRange | -| Document | 10 | line splitting, offset math, selection clamping, insert/delete, line endings, line ranges | +| Document | 12 | line splitting, offset math, selection clamping, insert/delete, line endings, line ranges, selected lines | | Document persistence | 2 | save/reload round-trip, `noURL` | | DocumentManager | 3 | reuse, neighbour focus, dirty tracking | | TextSelectionManager | 4 | primary selection, joined text | @@ -130,13 +130,13 @@ as a placeholder branch — plan only, no code. | Module | Lines | Files | |---|---|---| -| CodeEditorCore | 1437 | 8 | +| CodeEditorCore | 1451 | 8 | | CodeEditorLSP | 1143 | 3 | -| CodeEditorUI | 1843 | 11 | +| CodeEditorUI | 1839 | 11 | | CodeEditorApp | 484 | 2 | | CodeEditorThemes | 437 | 5 | | CodeEditorTerminal | 245 | 1 | -| Tests | 947 | 6 | +| Tests | 969 | 6 | LSP is the largest module after `CodeEditorUI` and the least used. That is deliberate for now — it is the interesting part — but if the next three issues do not land, it becomes diff --git a/docs/MEMORY.md b/docs/MEMORY.md index d8dc4e6..f3df908 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -264,6 +264,49 @@ explicit label even when the argument is a bare function reference. --- +## 16. `Scripts/build-app.sh` only fails from a clean checkout + +It shipped with `--show-bin-path` on both of its build commands. That flag *locates* the +output directory; it does not build. The script therefore succeeded whenever something else +had already run `swift build`, and failed with + +``` +install: .build/out/Products/Release/CodeEditorApp: No such file or directory +``` + +when run from a clean state — pointing at a binary that was never produced. + +**Run it from a clean checkout before believing it works:** + +```bash +rm -rf build && swift package clean && Scripts/build-app.sh +``` + +A build step that passes silently when it has nothing to do is worse than no build step. +The same reasoning applies to any script here: if it can succeed while doing nothing, it +will be believed until the one run that needs it. + +--- + +## 17. A caret sits on a line but does not select it + +`EditorState` derived `selectedLines` from `start.line...end.line`, which for a collapsed +caret is a one-element set. The status bar then read "1 line selected" on every freshly +opened file — the state was never wrong about the caret, only about what counts as a +selection. + +`Document.selectedLines(for:)` now returns an empty set for an empty selection. Callers that +want the caret's line use `lineAndColumn(for:)` instead of inferring it from a selection. + +Tests: `Document.collapsedCaretSelectsNothing`, `.selectionReportsCoveredLines`. + +Worth remembering as a shape: a summary shown to a user ("3 lines selected") is a claim +about a *state*, not about a *range*. Deriving it from the range without checking whether +the range is degenerate is how you get a readout that is wrong in the one case everybody +sees first. + +--- + ## Quick reference | Question | Answer | @@ -278,4 +321,6 @@ explicit label even when the argument is a bare function reference. | Where do I add a spacing value? | `Metrics` in Core. The 4pt grid is enforced by a test. | | Why did my row tap do nothing? | #12. Drive it from `selection`. | | Can I derive isDark from colours? | No. #11. | +| Why did the status bar say "1 line selected"? | #17. A caret is not a selection. | +| Does `Scripts/build-app.sh` build? | Only since the fix in #16. Run it from clean to check. | | Where do I write "this does not work"? | Here, and [CURRENT_STATE.md](CURRENT_STATE.md). | \ No newline at end of file From 4cd926c04431234c340ca2d82d732a766d6c2564 Mon Sep 17 00:00:00 2001 From: yan Date: Sat, 10 Oct 2026 00:03:33 +0300 Subject: [PATCH 16/22] docs: correct concepts the redesign made false Three claims had drifted from the code, and a stale document is worse than a missing one because it is trusted. MEMORY #8 and STACK.md both said the CodeEditSourceEditor exposure was contained to two files. Three import it: EditorState.swift has imported it for IndentOption all along. Counted, corrected, and the rule is now 'keep it at three' with the third named and the reason it is thin. STACK.md and ARCHITECTURE.md both described a UI-to-App notification seam. There was no seam left. The sidebar lost its Open Folder button when the empty state moved to the canvas, which left .codeEditorOpenFolder with a subscriber and no publisher; and .codeEditorOpenFiles posted and observed inside CodeEditorApp, a round trip to nowhere. Both names are deleted and presentOpenFilePanel calls AppState directly. Session restore was described as restoring 'the workspace and tabs', which reads as resuming work. It persists paths and never buffer content, so quitting with unsaved changes loses them silently while the tab still reopens. Stated plainly in CURRENT_STATE, README and the type's own MEMORY entry. Also: PROCESS and PROJECT_PLAN still claimed 35 tests, README's module table predated the Core and Themes additions, ARCHITECTURE had no account of the find-panel sync, session restore or quick open flows, and two anchors in AGENTS.md pointed at headings that do not exist. --- AGENTS.md | 6 ++-- PROJECT_PLAN.md | 2 +- README.md | 13 ++++---- .../CodeEditorApp/Sources/CodeEditorApp.swift | 20 ++----------- .../Sources/Sidebar/FileExplorerView.swift | 10 ------- docs/ARCHITECTURE.md | 28 +++++++++++++++-- docs/CURRENT_STATE.md | 7 +++++ docs/MEMORY.md | 25 ++++++++++++---- docs/PROCESS.md | 2 +- docs/STACK.md | 30 +++++++++++++------ 10 files changed, 89 insertions(+), 54 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2f1260d..7c6b755 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,7 +94,8 @@ Never imply in a commit message or PR that CI verified the build. It did not. - **No magic numbers in chrome.** Spacing, radii and fixed heights come from `Metrics` (`CodeEditorCore`); the 4pt grid is enforced by a test. Chrome type comes from `Typography` — semantic styles only, so it scales with the user's text size. Code keeps a - fixed-size SF Mono. See [MEMORY.md](docs/MEMORY.md#11) for why these rules exist. + fixed-size SF Mono. See [MEMORY.md](docs/MEMORY.md#11-never-derive-appearance-from-colour-arithmetic) + for why these rules exist. ## Traps that will bite @@ -108,7 +109,8 @@ Never imply in a commit message or PR that CI verified the build. It did not. - A `.onTapGesture` on a row inside a `List(selection:)` never fires. Drive row behaviour from the selection change. See [MEMORY.md #12](docs/MEMORY.md#12-a-tap-gesture-inside-a-selection-list-is-swallowed). - Do not put a `.frame` on a `NavigationSplitView` column; `.navigationSplitViewColumnWidth` - already sizes it. See [MEMORY.md #13](docs/MEMORY.md). + already sizes it. See + [MEMORY.md #13](docs/MEMORY.md#13-navigationsplitview-columns-are-sized-in-one-place). ## Before you call it done diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 1d86bee..9c14b90 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -30,7 +30,7 @@ tracked in GitHub, ordered in [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md). ## Quality gates - [x] Builds with no warnings -- [x] `swift test` passes (35 tests) +- [x] `swift test` passes (68 tests) - [x] `swift-format lint --strict` clean - [ ] CI builds the package — blocked, see [docs/MEMORY.md](docs/MEMORY.md#1-ci-cannot-build-this-package-do-not-try-to-make-it) - [ ] Completion works for Swift diff --git a/README.md b/README.md index 99d779f..308a1eb 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,9 @@ an embedded shell in the same window. the divider above it to resize; the height is remembered. - **Themes** — Dark, Light and High Contrast, persisted across launches along with font size, tab width, word wrap, minimap, folding ribbon and invisibles. -- **Session restore** — reopens your workspace and tabs on launch. Untitled documents are - not restored. +- **Session restore** — reopens your workspace and tabs on launch. It restores *what you + had open*, not your edits: unsaved buffers are not persisted, and untitled documents + have no path to replay. - **Menu commands** — ⌘N, ⌘O, ⇧⌘O, ⌘F, ⌘P, ⌘S, ⌥⌘S, ⌘W, ⌘B, ⌃`, ⇧⌘L for theme. ### Interface notes @@ -118,12 +119,12 @@ add a `build` job and drop the caveat. | Module | Responsibility | |---|---| -| `CodeEditorCore` | `Document`, `DocumentManager`, `TextSelectionManager`, `FileSystemManager`. No UI. | -| `CodeEditorThemes` | `Theme`, `EditorColor`, `ThemeManager` with persisted selection. | +| `CodeEditorCore` | `Document`, `DocumentManager`, `TextSelectionManager`, `FileSystemManager`, `FileOutline`, `QuickOpenMatcher`, `QuickOpenIndex`, `SessionStore`, `Metrics`. No UI — Foundation only. | +| `CodeEditorThemes` | `Theme`, `Chrome`, `Semantic`, `Typography`, `EditorColor`, `ThemeManager` with persisted selection. | | `CodeEditorTerminal` | `TerminalSession`: shell subprocess, line-buffered scrollback, history. | | `CodeEditorLSP` | `JSONRPCTransport` (Content-Length framing) and `LSPClient`. Not yet wired into the UI. | -| `CodeEditorUI` | SwiftUI views: editor, sidebar, terminal, plus `AppState`. | -| `CodeEditorApp` | The `@main` App, menu commands, window layout. | +| `CodeEditorUI` | SwiftUI views: editor, sidebar, tabs, status bar, empty state, quick open, terminal, plus `AppState`. | +| `CodeEditorApp` | The `@main` App, menu commands, window layout, toolbar. | Dependencies flow one way: `Core` ← `UI` ← `App`. `Themes`, `Terminal` and `LSP` are leaves that `UI` composes. diff --git a/Sources/CodeEditorApp/Sources/CodeEditorApp.swift b/Sources/CodeEditorApp/Sources/CodeEditorApp.swift index d0717f5..bd2d7bb 100644 --- a/Sources/CodeEditorApp/Sources/CodeEditorApp.swift +++ b/Sources/CodeEditorApp/Sources/CodeEditorApp.swift @@ -14,19 +14,6 @@ struct CodeEditorApp: App { Window("Code Editor", id: "main-window") { MainWindowView(appState: appState) .frame(minWidth: 720, minHeight: 480) - .onReceive( - NotificationCenter.default.publisher(for: .codeEditorOpenFolder) - ) { _ in - appState.openFolder() - } - .onReceive( - NotificationCenter.default.publisher(for: .codeEditorOpenFiles) - ) { notification in - guard let urls = notification.object as? [URL] else { return } - for url in urls { - appState.openFile(url) - } - } } .defaultSize(width: 1200, height: 800) .commands { commands } @@ -182,9 +169,8 @@ struct CodeEditorApp: App { panel.prompt = "Open" guard panel.runModal() == .OK else { return } - NotificationCenter.default.post( - name: .codeEditorOpenFiles, - object: panel.urls - ) + for url in panel.urls { + appState.openFile(url) + } } } diff --git a/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift b/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift index 2ccf41a..db257d6 100644 --- a/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift +++ b/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift @@ -264,13 +264,3 @@ public struct FileExplorerView: View { } } } - -// MARK: - Notifications - -extension Notification.Name { - /// Posted by the sidebar to ask the app to present an open-folder panel. - public static let codeEditorOpenFolder = Notification.Name("CodeEditor.openFolder") - - /// Posted by the app with an array of `URL`s to open in the editor. - public static let codeEditorOpenFiles = Notification.Name("CodeEditor.openFiles") -} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b8f87d1..c743e01 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -81,6 +81,26 @@ would need a re-entrancy guard against the text view writing while SwiftUI is mi **Consequence:** `Document` mutations that originate outside the text view (nothing today) would fight the binding. If you add one, route it through the view, not around it. +## Three more flows worth knowing + +**Find panel.** One flag, two owners. `EditorState.isFindVisible` is ours; +`SourceEditorState.findPanelVisible` is the editor's. `CodeEditorView` syncs them both ways, +and the panel is the authority: when the user dismisses the panel, the coordinator writes +`findPanelVisible = false`, which we mirror back onto `EditorState`. ⌘F only ever sets our +flag. Nothing in the app draws a find UI. + +**Session restore.** `AppState` is the `DocumentManagerDelegate`, so opening and closing a +document persists a `SessionSnapshot` — workspace root, open tab paths, active path — to +`UserDefaults` via `SessionStore`. On launch it replays it. Untitled documents are skipped +because they have no path to replay. A vanished root clears the snapshot rather than leaving +a window that restores nothing. + +**Quick open.** `QuickOpenIndex` walks the workspace off the main actor in batches and +publishes each batch only if its generation is still current, so a superseded walk is +dropped rather than interleaved. `QuickOpenMatcher` scores candidates per keystroke against +what is already indexed; the palette renders and nothing else. It is a snapshot — see +[CURRENT_STATE.md](CURRENT_STATE.md). + ## Caching and identity `FileSystemManager` keys its directory cache on `url.standardizedFileURL.path`, not `URL`. @@ -166,8 +186,12 @@ Anything that grows a filesystem dependency should use it. state disagrees with it. The panel stays the authority on its own visibility. - `TreeSitterClient.Constants.longParse` — a notification name from the upstream module. The editor surfaces it as a parse indicator; nothing else depends on it. -- `Notification.Name.codeEditorOpenFolder` / `codeEditorOpenFiles` — `UI` posts, `App` - observes. Replace with a closure when a second consumer appears. +- `Notification.Name.codeEditorOpenFolder` / `codeEditorOpenFiles` — **removed.** The seam + existed because the sidebar used to own an "Open Folder" button and could not call + `AppState` directly. It can now: the sidebar takes an `onCreateIn` closure and the canvas + empty state takes `onOpenFolder`, and the panel itself lives in `AppState`. Both + notifications posted and observed inside `CodeEditorApp`, which is a round trip to + nowhere. Call closures. - `FileSystemManager.load` returns rows synchronously from a cache while populating it asynchronously. The view re-renders as children arrive. Fine for a tree; would need thought for a list that must not reflow. diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 7a2fc93..468424c 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -82,6 +82,13 @@ security-scoped bookmarks. See [MEMORY.md](MEMORY.md). **Untitled documents are not restored.** They have no stable identity across launches. +**Session restore reopens paths, not edits.** The snapshot stores the workspace root, tab +paths and active path — never buffer content. Quit with unsaved changes and the tab comes +back showing what is on disk, with the edits gone and no prompt on the way out. Restoring +buffers would mean writing them somewhere, and there is no untitled-document story yet +either. The honest version of this feature is "reopens what you had open", not "resumes +your session". + **No multi-cursor.** `TextSelectionManager` models multiple selections and `Document` stores them, but the text view drives a single caret. diff --git a/docs/MEMORY.md b/docs/MEMORY.md index f3df908..c6708f7 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -156,14 +156,21 @@ interop and belongs behind a testable protocol. Pinned `from: "0.15.2"`, so 0.16 lands automatically. That is intentional. -Exposure is contained to two files: +Exposure is contained to three files, all in `Editor/`: -- `Sources/CodeEditorUI/Sources/Editor/EditorTheme+Bridge.swift` — the only place that - constructs `EditorTheme.Attribute` -- `Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift` — `SourceEditor` and - `SourceEditorConfiguration` +- `EditorTheme+Bridge.swift` — the only place that constructs `EditorTheme.Attribute` +- `CodeEditorView.swift` — `SourceEditor`, `SourceEditorConfiguration` and its nested + `Appearance` / `Behavior` / `Peripherals`, `InvisibleCharactersConfiguration`, + `BracketPairEmphasis` +- `EditorState.swift` — `IndentOption` only, because indent is an editor preference -Keep it that way. If a third file starts naming upstream types, the boundary has leaked. +The first two are the deliberate boundary. The third is a thin type reference; if you move +`IndentOption` out of the editor's configuration, that import can go too. + +**Keep it at three.** If a fourth file starts naming upstream types, the boundary has +leaked. The count was wrong in the docs for a while — they said two while `EditorState.swift` +had been importing it all along — which is the reason to go and count instead of repeating +the claim. Known break: 0.8.1 → 0.15 changed the API completely (`CodeEditorSourceEditor` with `cursorPositions:` became `SourceEditor` with `state: SourceEditorState`), and 0.8.1 no @@ -305,6 +312,12 @@ about a *state*, not about a *range*. Deriving it from the range without checkin the range is degenerate is how you get a readout that is wrong in the one case everybody sees first. +The same shape bit the session snapshot, and it is worth checking every "restore" feature +against: it persists *paths*, never buffer content. Quit with unsaved changes and the tab +returns showing what is on disk — the edits are gone and nothing said so on the way out. +A feature named "restore" will be read as "resume"; say which one it is, in the docs and +in the UI. + --- ## Quick reference diff --git a/docs/PROCESS.md b/docs/PROCESS.md index 8001194..2a0e4ef 100644 --- a/docs/PROCESS.md +++ b/docs/PROCESS.md @@ -20,7 +20,7 @@ swift build && swift test swift-format lint --recursive --strict --configuration .swift-format Sources Tests ``` -All three, before every push. 35 tests, about 30 seconds. +All three, before every push. 68 tests in 16 suites, about 30 seconds. `swift-format format --in-place --recursive --configuration .swift-format Sources Tests` when lint complains about formatting. diff --git a/docs/STACK.md b/docs/STACK.md index 46ca133..1006541 100644 --- a/docs/STACK.md +++ b/docs/STACK.md @@ -6,7 +6,7 @@ |---|---|---| | Swift | 6.4 (6.4.0.34.1) | `swift-tools-version: 6.4`, Swift 6 language mode | | Xcode | 16+ | `ApproachableConcurrency` enabled per target | -| macOS deployment | 14.0 | `@Observable`, `.onChange(of:initial:)` | +| macOS deployment | 14.0 | `@Observable`, `.onChange(of:initial:)`, `.onKeyPress`, `.formStyle(.grouped)` | | Build system | SwiftPM | no Xcode project, no workspace | Developed against macOS 27.0.1 arm64. Nothing in the code is version-specific above 14.0; @@ -39,9 +39,17 @@ wired, and running two text engines in one app buys nothing. the `SourceEditor` + `SourceEditorConfiguration` shape this code targets. Pinning is `from: "0.15.2"`, so 0.16 will be picked up automatically. That is deliberate: -the upstream package is pre-1.0 and moves. The exposure is contained to one file — -`Sources/CodeEditorUI/Sources/Editor/EditorTheme+Bridge.swift` — which is the only place -that touches upstream types beyond `SourceEditor` itself. See [MEMORY.md](MEMORY.md). +the upstream package is pre-1.0 and moves. The exposure is contained to three files under +`Sources/CodeEditorUI/Sources/Editor/`: `CodeEditorView.swift` (`SourceEditor`, +`SourceEditorConfiguration` and its nested `Appearance`/`Behavior`/`Peripherals`, +`InvisibleCharactersConfiguration`, `BracketPairEmphasis`), `EditorTheme+Bridge.swift` +(`EditorTheme` and its `Attribute`), and `EditorState.swift` for `IndentOption` alone. A +fourth file naming upstream types means the boundary has leaked. See +[MEMORY.md](MEMORY.md#8-codeeditsourceeditor-is-pre-10-and-moves). + +0.15 also ships the pieces this app had no reason to rebuild: a find-and-replace panel +driven by `SourceEditorState.findPanelVisible`, code-folding ribbon, minimap, invisibles +and warning-character rendering. Finding them meant not designing a ⌘F UI at all. ## Tooling @@ -69,10 +77,11 @@ compile errors in the original scaffold were exactly this. **`NavigationSplitView` for the layout.** Resizable sidebar, system-standard behaviour, free on macOS 14+. -**Notifications between `UI` and `App`.** `FileExplorerView` posts `.codeEditorOpenFolder`; -`CodeEditorApp` observes it. This keeps `NSOpenPanel` out of the view and avoids `UI` -importing `AppKit` panel code. It is a small seam to replace with a closure when a second -consumer appears. +**No notifications between `UI` and `App`.** There used to be: the sidebar posted +`.codeEditorOpenFolder` because it could not call `AppState`. It can — views take closures +(`onOpenFile`, `onCreateIn`, `onOpenFolder`) and the panel logic lives in `AppState`, not in +a view. Both notification names are gone. `NSOpenPanel` still does not appear in the view +layer, because `AppState` owns the commands. ## Alternatives rejected @@ -82,4 +91,7 @@ consumer appears. | Write our own text view | NSTextView already does selection, undo, IME, accessibility. Reimplementing is a year. | | Runestone | Duplicate text engine, no tree-sitter integration. | | SwiftLSPClient | Abandoned upstream. The LSP layer here is small and worth owning. | -| `develop` branch | One line of work. Doubles merge cost for nothing. See [BRANCHING.md](BRANCHING.md). | \ No newline at end of file +| `develop` branch | One line of work. Doubles merge cost for nothing. See [BRANCHING.md](BRANCHING.md). | +| Hand-rolled find/replace | 0.15 ships one. See above. | +| Hand-rolled folding ribbon, minimap, invisibles | 0.15 ships all three as `Peripherals`. | +| More built-in themes | Each one is a palette someone has to maintain and review, for a choice most users make once. Three is enough. | \ No newline at end of file From 1cb974abc186130db6000cf7ab01fda1d2271ea8 Mon Sep 17 00:00:00 2001 From: yan Date: Sat, 10 Oct 2026 00:14:40 +0300 Subject: [PATCH 17/22] docs: record the redesign issue and a recommendation for what comes next MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #14 tracks the redesign. CURRENT_STATE's Next section no longer lists the six issues in numeric order, because the redesign changed which are cheap: it recommends guarding unsaved work on quit first (the only remaining path to data loss, and not an issue yet), then #1 because SourceEditor already takes a completionDelegate and the suggestion UI ships upstream, then #6, then #2 and #3 together since they share the coordinator #1 proves. It also says what is deliberately not next — more themes, multi-window, Finder file associations — because a recommendation that only says what to do leaves the obvious options open. The gap itself is stated in Not working rather than only in the roadmap: there is no applicationShouldTerminate handler, so the app does not ask before losing unsaved edits. PROJECT_PLAN gains #14, two unchecked gates for the data-loss guard and the visual review, and two risks this round earned: a build script that passes while doing nothing, and a document that outlives its code. --- PROJECT_PLAN.md | 11 ++++-- docs/CURRENT_STATE.md | 90 ++++++++++++++++++++++++++++++++++++------- docs/IDEA.md | 8 +++- 3 files changed, 92 insertions(+), 17 deletions(-) diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 9c14b90..a0bad3f 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -25,7 +25,8 @@ tracked in GitHub, ordered in [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md). | 3 | [Hover and go-to-definition](https://github.com/Yan123-tech/code-editor/issues/3) | Tooltips and ⌘-click navigation | | 6 | [External change detection](https://github.com/Yan123-tech/code-editor/issues/6) | Reload clean files, prompt on dirty ones | | 5 | [PTY terminal](https://github.com/Yan123-tech/code-editor/issues/5) | Real pseudo-terminal, ANSI parsing, SIGINT forwarding | -| 4 | [Settings panel](https://github.com/Yan123-tech/code-editor/issues/4) | Replace menu-only preference controls | +| 4 | [Settings panel](https://github.com/Yan123-tech/code-editor/issues/4) | Move persisted preferences out of the Format menu into a discoverable surface | +| 14 | [Interface redesign](https://github.com/Yan123-tech/code-editor/issues/14) | Landed: native macOS chrome and a real design system | ## Quality gates @@ -36,13 +37,17 @@ tracked in GitHub, ordered in [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md). - [ ] Completion works for Swift - [ ] Diagnostics render - [ ] No leaks in a 30-minute session +- [ ] Unsaved work is guarded on quit — **the one remaining path to data loss**, and it is not an issue yet +- [ ] The redesign has been reviewed by someone who can see it ## Risks | Risk | Mitigation | |---|---| -| Source editor API churn (upstream is pre-1.0) | Pinned `from: 0.15.2`; `Theme.editorTheme` in `EditorTheme+Bridge.swift` isolates the surface we touch. [MEMORY.md #8](docs/MEMORY.md) | +| Source editor API churn (upstream is pre-1.0) | Pinned `from: 0.15.2`; three files under `Editor/` name upstream types and no more. [MEMORY.md #8](docs/MEMORY.md) | | `sourcekit-lsp` is Xcode-toolchain specific | `LanguageServerRegistry.sourceKitLSPPath` probes known paths and returns `nil` rather than failing to launch | | URL equality treats `/tmp/x` and `/tmp/x/` as distinct | `FileSystemManager` keys its cache on `standardizedFileURL.path`. [MEMORY.md #3](docs/MEMORY.md) | | Terminal uses pipes, not a PTY | Documented in `TerminalSession`, tracked as issue #5 | -| LSP is the largest module and unused | If issues 1–3 do not land, delete it rather than keep it aspirational. [CURRENT_STATE.md](docs/CURRENT_STATE.md) | \ No newline at end of file +| LSP is the largest module and unused | If issues 1–3 do not land, delete it rather than keep it aspirational. [CURRENT_STATE.md](docs/CURRENT_STATE.md) | +| A build script that passes while doing nothing | `Scripts/build-app.sh` shipped with `--show-bin-path` on its build commands and only ever failed from a clean checkout. Run it from clean. [MEMORY.md #16](docs/MEMORY.md#16-scriptsbuild-appsh-only-fails-from-a-clean-checkout) | +| A document that outlives its code | The redesign broke three claims in `docs/`, one of them describing a notification seam that no longer had a poster. Auditing the `.md` files against the code is part of the work, not after it. [AGENTS.md](AGENTS.md) | \ No newline at end of file diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 468424c..b60d22a 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -9,7 +9,12 @@ swift-format lint --strict → 0 warnings ``` Tagged **0.1.0**. The interface has been redesigned since that tag; see -[Redesigned interface](#redesigned-interface). +[Redesigned interface](#redesigned-interface) and +[#14](https://github.com/Yan123-tech/code-editor/issues/14). + +**Start here:** the highest-priority next step is not one of the six issues — it is guarding +unsaved work on quit, which is the only remaining path to data loss. See +[Next](#next). ## Redesigned interface @@ -82,12 +87,16 @@ security-scoped bookmarks. See [MEMORY.md](MEMORY.md). **Untitled documents are not restored.** They have no stable identity across launches. -**Session restore reopens paths, not edits.** The snapshot stores the workspace root, tab -paths and active path — never buffer content. Quit with unsaved changes and the tab comes -back showing what is on disk, with the edits gone and no prompt on the way out. Restoring -buffers would mean writing them somewhere, and there is no untitled-document story yet -either. The honest version of this feature is "reopens what you had open", not "resumes -your session". +**Session restore reopens paths, not edits, and nothing guards unsaved work on quit.** The +snapshot stores the workspace root, tab paths and active path — never buffer content. Quit +with unsaved changes and the tab comes back showing what is on disk, with the edits gone and +no prompt on the way out. There is no `applicationShouldTerminate` handler, so the app does +not even ask. Restoring buffers would mean writing them somewhere, and there is no +untitled-document story yet either. The honest version of this feature is "reopens what you +had open", not "resumes your session". + +This is the only remaining path to data loss in the app, which is why it tops +[Next](#next). **No multi-cursor.** `TextSelectionManager` models multiple selections and `Document` stores them, but the text view drives a single caret. @@ -123,12 +132,67 @@ extracted into `CodeEditorCore`, and the LSP transport. ## Next -1. [#1](https://github.com/Yan123-tech/code-editor/issues/1) — connect `LSPClient` via a `TextViewCoordinator`; completion popup -2. [#2](https://github.com/Yan123-tech/code-editor/issues/2) — diagnostics overlay and gutter markers -3. [#3](https://github.com/Yan123-tech/code-editor/issues/3) — hover and go-to-definition -4. [#6](https://github.com/Yan123-tech/code-editor/issues/6) — external change detection -5. [#5](https://github.com/Yan123-tech/code-editor/issues/5) — PTY terminal -6. [#4](https://github.com/Yan123-tech/code-editor/issues/4) — settings panel +The redesign ([#14](https://github.com/Yan123-tech/code-editor/issues/14), +[PR #13](https://github.com/Yan123-tech/code-editor/pull/13)) changed what is cheap and what +is not, so the old ordering no longer holds. Below is what I would pick next and why — +this is a recommendation, not a commitment. The issues are unchanged. + +### 1. Guard unsaved work before anything else + +**No issue yet — worth opening.** Session restore persists paths, never buffer content, and +there is no prompt when the app terminates. Quit with unsaved edits and the tab reopens +showing what is on disk, with the work gone and nothing said. This is the only gap that can +lose data, and the redesign made it more visible by making restore feel like it resumes. + +Small: a termination handler that asks when modified documents are open, plus a decision +about whether to persist buffers or not. Data loss outranks features. + +### 2. #1 — LSP completion + +The client already works; nothing calls it. `SourceEditor` takes a `completionDelegate`, so +this is wiring rather than design — the least code for the most visible gain, and the +project's stated purpose ([IDEA.md](IDEA.md)). The suggestion UI already exists upstream too; +the same way ⌘F did not need building. + +### 3. #6 — external change detection + +A file changed on disk while open is silently overwritten. Also data loss, but narrower: it +needs someone else editing the same file. A prompt on save when the modification date moved +is enough to start; FSEvents is not required for correctness here. + +### 4. #2 then #3 — diagnostics, hover, go-to-definition + +Follow #1 naturally: they share the transport and lifecycle that #1 proves. Doing them +together means the `TextViewCoordinator` wiring is designed once. + +### 5. #4 — settings panel + +Smallest of the six. Minimap, folding ribbon, invisibles, wrap, tab width and font size are +already toggles in the Format menu and already persisted; the panel is moving existing +state into a discoverable surface, not new behaviour. + +### 6. #5 — PTY terminal + +Largest and least user-visible until done. `vim` and `top` are broken today, which makes it +feel urgent, but it is C interop behind a protocol and nothing else depends on it. + +### Deliberately not next + +- **More themes.** Each one is a palette someone maintains and reviews, for a choice most + users make once. Three is enough. +- **Multi-window.** Needs per-window `AppState` first. The `Window` scene is the honest + single-window shape, not a compromise. +- **Finder file associations.** Needs `CFBundleDocumentTypes` in `App-Info.plist` and + `.handlesExternalEvents`. Small, but it is packaging rather than capability, and it + should ride along with something that needs double-clicking files to work — which is + completion via ⌘P, not the other way round. + +### Housekeeping before the next branch + +- [#14](https://github.com/Yan123-tech/code-editor/issues/14) has one unchecked box: a human + visual review of the redesign. Everything else about it is verified except that. +- `Scripts/build-app.sh` needs running from a clean checkout before it is believed again. + See [MEMORY.md #16](MEMORY.md#16-scriptsbuild-appsh-only-fails-from-a-clean-checkout). `feature/terminal-pty` and [PR #7](https://github.com/Yan123-tech/code-editor/pull/7) exist as a placeholder branch — plan only, no code. diff --git a/docs/IDEA.md b/docs/IDEA.md index e5af664..7ef81ac 100644 --- a/docs/IDEA.md +++ b/docs/IDEA.md @@ -75,7 +75,13 @@ exists because of it. ## Where it is going Near term, connect the LSP client that already works: completion, diagnostics, hover, -go-to-definition. That is issues [#1](https://github.com/Yan123-tech/code-editor/issues/1)–[#3](https://github.com/Yan123-tech/code-editor/issues/3). +go-to-definition. That is issues [#1](https://github.com/Yan123-tech/code-editor/issues/1)–[#3](https://github.com/Yan123-tech/code-editor/issues/3). It is cheap because 0.15 already +ships the UI — completion delegates, a find panel, folding, a minimap. The project's edge +was never the chrome; it is the layer underneath. + +The interface itself was rebuilt in +[#14](https://github.com/Yan123-tech/code-editor/issues/14), which is what makes the +remaining work visible rather than buried in a prototype. Longer term it is unclear whether this grows into a full editor or stays a focused tool. That is a decision to make when there is more evidence about what is missing, not now. From dcf4acf9d32fbb311551055b91a14e7c0f20a365 Mon Sep 17 00:00:00 2001 From: yan Date: Sat, 10 Oct 2026 00:23:12 +0300 Subject: [PATCH 18/22] fix(ui): three defects found in visual review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every tab showed the same file. SourceEditor pushes text into its text view exactly once, in makeNSViewController; updateNSViewController diffs language, configuration and highlight providers, never the text behind the Binding. Switching tabs therefore kept the previous document on screen. DocumentTextCoordinator captures the controller in prepareCoordinator — the one hook that hands it out — and CodeEditorView reloads it when the document changes. Verified in the running app: switching tabs now changes the editor from README content to index.html content. A folder's chevron stopped working once the folder was selected. Expansion hangs off the selection change, and clicking an already selected row changes nothing, so no toggle fired. The chevron is a real Button again; it selects the row first so the two paths cannot cancel. The line-number gutter bled onto the tab labels while rubber-band scrolling. The strip sits flush against the editor and a translucent material lets an overscroll NSScrollView draw through it. Both bars now carry an opaque canvas base under the material. Also: the tab label was rendering both a dirty dot and the " *" that Document.title appends. One state, one indicator. --- .../Sources/Chrome/StatusBarView.swift | 5 +-- .../Sources/Chrome/TabBarView.swift | 15 ++++++--- .../Sources/Editor/CodeEditorView.swift | 8 ++++- .../Editor/DocumentTextCoordinator.swift | 33 +++++++++++++++++++ .../Sources/Sidebar/FileExplorerView.swift | 23 +++++++++---- 5 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 Sources/CodeEditorUI/Sources/Editor/DocumentTextCoordinator.swift diff --git a/Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift b/Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift index 7ba24de..6e7e4ab 100644 --- a/Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift +++ b/Sources/CodeEditorUI/Sources/Chrome/StatusBarView.swift @@ -47,12 +47,13 @@ public struct StatusBarView: View { } } + /// Opaque base first, then material: the status bar also sits flush against a scroll view, + /// and a translucent fill lets overscrolled content bleed through. See `TabBarView.stripFill`. @ViewBuilder private var backgroundFill: some View { + Rectangle().fill(appState.theme.background.color) if appState.theme.chrome.usesMaterials { Rectangle().fill(.thinMaterial) - } else { - Rectangle().fill(appState.theme.chrome.barBackground.color) } } diff --git a/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift b/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift index b2b9d43..4c7661e 100644 --- a/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift +++ b/Sources/CodeEditorUI/Sources/Chrome/TabBarView.swift @@ -63,13 +63,18 @@ public struct TabBarView: View { // MARK: - Strip - /// Materials when the palette allows them; the token fill otherwise. + /// An opaque canvas-coloured base under the material. + /// + /// The strip sits directly above the editor with no separator, and an `NSScrollView` + /// draws its content outside its bounds while rubber-band scrolling. A translucent + /// material lets that overscrolled content — including the gutter — show through and + /// appear to overlap the tab labels. The base kills the bleed-through while keeping the + /// material's tint and the window-focus response. @ViewBuilder private var stripFill: some View { + Rectangle().fill(theme.background.color) if theme.chrome.usesMaterials { Rectangle().fill(.thinMaterial) - } else { - Rectangle().fill(theme.chrome.barBackground.color) } } @@ -105,7 +110,9 @@ public struct TabBarView: View { .help("Unsaved changes") } - Text(document.title) + // `name`, not `title`: title appends " *" when modified, and the dot + // beside the label already says that. Two indicators for one state. + Text(document.name) .font(Typography.tabLabel) .lineLimit(1) .truncationMode(.middle) diff --git a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift index 2bf81cc..a713854 100644 --- a/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift +++ b/Sources/CodeEditorUI/Sources/Editor/CodeEditorView.swift @@ -13,6 +13,7 @@ public struct CodeEditorView: View { let editorState: EditorState @State private var sourceEditorState = SourceEditorState() + @State private var textCoordinator = DocumentTextCoordinator() public init(document: CodeEditorCore.Document, theme: Theme, editorState: EditorState) { self.document = document @@ -26,13 +27,18 @@ public struct CodeEditorView: View { language: document.language.codeLanguage, configuration: configuration, state: $sourceEditorState, - undoManager: undoManager + undoManager: undoManager, + coordinators: [textCoordinator] ) .background(theme.background.color) .onChange(of: sourceEditorState.cursorPositions) { _, positions in syncSelection(with: positions) } .onChange(of: document.id) { _, _ in + // SourceEditor does not diff text on update — only language, configuration + // and highlight providers. Without this, switching tabs keeps the previous + // document on screen. + textCoordinator.reload(text: document.content) editorState.syncSelection(of: document) } .task { diff --git a/Sources/CodeEditorUI/Sources/Editor/DocumentTextCoordinator.swift b/Sources/CodeEditorUI/Sources/Editor/DocumentTextCoordinator.swift new file mode 100644 index 0000000..113b081 --- /dev/null +++ b/Sources/CodeEditorUI/Sources/Editor/DocumentTextCoordinator.swift @@ -0,0 +1,33 @@ +import CodeEditSourceEditor + +/// Keeps the text view's contents in step with the document it is showing. +/// +/// `SourceEditor` pushes text into its text view exactly once, in +/// `makeNSViewController`. `updateNSViewController` diffs language, configuration and +/// highlight providers — **it never diffs the text behind the `Binding`**. Switching +/// tabs therefore leaves the previously opened document on screen, and every tab shows the +/// same file. +/// +/// `prepareCoordinator` is the one hook that hands out the live controller, and it runs once +/// per controller, so this holds it weakly and `CodeEditorView` calls `reload(text:)` when +/// the document changes. +/// +/// This is a workaround for upstream, not a design preference. If a future version diffs the +/// text on update, this type should go away — see [MEMORY.md](../../../docs/MEMORY.md). +@MainActor +final class DocumentTextCoordinator: TextViewCoordinator { + private weak var controller: TextViewController? + + func prepareCoordinator(controller: TextViewController) { + self.controller = controller + } + + /// Replace the editor's contents, unless it is already showing them. + /// + /// Safe either way: the change flows back through the binding, and `Document.setContent` + /// guards on equality, so reloading the same text records no edit and no undo entry. + func reload(text: String) { + guard let controller, controller.textView.string != text else { return } + controller.setText(text) + } +} diff --git a/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift b/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift index db257d6..814ca67 100644 --- a/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift +++ b/Sources/CodeEditorUI/Sources/Sidebar/FileExplorerView.swift @@ -187,16 +187,25 @@ public struct FileExplorerView: View { } } - /// The disclosure chevron. Decorative, not a control: selecting the row is what - /// expands a folder, so a second tap target here would toggle it twice. + /// The disclosure chevron. A real control: expansion hangs off the selection change, + /// so a folder that is *already* selected produces no change and nothing would toggle. + /// Selecting it first also makes the row selection agree with the chevron, which is + /// what stops the two from cancelling out. @ViewBuilder private func disclosureControl(for item: FileSystemItem) -> some View { if item.isDirectory { - Image(systemName: fileSystemManager.isExpanded(item.url) ? "chevron.down" : "chevron.right") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(.tertiary) - .frame(width: 12, height: 12) - .accessibilityHidden(true) + Button { + selection = item + fileSystemManager.toggleExpansion(of: item) + } label: { + Image(systemName: fileSystemManager.isExpanded(item.url) ? "chevron.down" : "chevron.right") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.tertiary) + .frame(width: 12, height: 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(fileSystemManager.isExpanded(item.url) ? "Collapse" : "Expand") } else { Color.clear.frame(width: 12, height: 12) } From e9939ea9aa9e414b04ab516c1dd09e7d488af238 Mon Sep 17 00:00:00 2001 From: yan Date: Sat, 10 Oct 2026 11:48:01 +0300 Subject: [PATCH 19/22] docs: record the three review findings as issues and memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #15 every tab showed the first file opened — upstream does not diff text on update, and the coordinator that works around it looks redundant enough that the next person would delete it without reading why. MEMORY.md #18 exists for that, with the upstream code quoted and the .id(document.id) fallback recorded. #16 a selected folder's chevron did nothing — the direct consequence of driving expansion off the selection change. Cross-linked from #12, which explains why the tap gesture was removed in the first place. #17 the gutter drew through the translucent tab strip. Marked honestly as a hypothesis rather than a diagnosis: it cannot be reproduced synthetically, and the competing explanation is the editor's content insets, with the next thing to try written down. CURRENT_STATE now records that the visual review happened and found three things, none of which were visible without someone looking at the running app. ARCHITECTURE accounts for the fourth upstream-touching file. --- docs/ARCHITECTURE.md | 4 ++++ docs/CURRENT_STATE.md | 15 ++++++++++++--- docs/MEMORY.md | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 3 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c743e01..6c001cd 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -181,6 +181,10 @@ Anything that grows a filesystem dependency should use it. - `SourceEditor` and `EditorTheme.Attribute` — contained in `CodeEditorView.swift` and `EditorTheme+Bridge.swift`. Both are upstream, both are pre-1.0. See [STACK.md](STACK.md). +- `DocumentTextCoordinator` — a `TextViewCoordinator` that captures the live + `TextViewController` so `CodeEditorView` can reload its text. Exists because + `updateNSViewController` does not diff the text behind the binding. A fourth upstream-type + file is expected and accounted for; see [MEMORY.md #18](MEMORY.md). - `SourceEditorState.findPanelVisible` — `EditorState.isFindVisible` is our flag, `CodeEditorView` syncs it both ways, and the upstream coordinator opens the panel when state disagrees with it. The panel stays the authority on its own visibility. diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index b60d22a..9cf0e39 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -43,7 +43,14 @@ Create file, create folder, rename, delete, reveal in Finder, copy path. Dotfile New items land beside the active document, or at the workspace root. **Editing.** `SourceEditor` from CodeEditSourceEditor 0.15, bound two-way to -`Document.content`. Tree-sitter highlighting across roughly 25 languages, line-number +`Document.content`. Three defects found in visual review of [#14](https://github.com/Yan123-tech/code-editor/issues/14) +were fixed: every tab showed the first file opened +([#15](https://github.com/Yan123-tech/code-editor/issues/15) — upstream does not diff text on +update), a selected folder's chevron did nothing +([#16](https://github.com/Yan123-tech/code-editor/issues/16)), and the gutter drew through the +translucent tab strip while rubber-band scrolling +([#17](https://github.com/Yan123-tech/code-editor/issues/17) — fix applied, not yet +confirmed). Tree-sitter highlighting across roughly 25 languages, line-number gutter, bracket-pair emphasis, indentation and reformat-at-column, word wrap, undo stack shared with the document model. Peripherals are configured explicitly: gutter on, minimap off by default with a toggle, folding ribbon and invisibles toggleable, smart quotes and @@ -189,8 +196,10 @@ feel urgent, but it is C interop behind a protocol and nothing else depends on i ### Housekeeping before the next branch -- [#14](https://github.com/Yan123-tech/code-editor/issues/14) has one unchecked box: a human - visual review of the redesign. Everything else about it is verified except that. +- [#16](https://github.com/Yan123-tech/code-editor/issues/16) and + [#17](https://github.com/Yan123-tech/code-editor/issues/17) are fixed but were not driven in + the running app — synthetic accessibility events do not reach SwiftUI rows, and the gutter + overlap only appears mid-gesture. Both need a second look. - `Scripts/build-app.sh` needs running from a clean checkout before it is believed again. See [MEMORY.md #16](MEMORY.md#16-scriptsbuild-appsh-only-fails-from-a-clean-checkout). diff --git a/docs/MEMORY.md b/docs/MEMORY.md index c6708f7..2ca18a0 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -320,6 +320,37 @@ in the UI. --- +## 18. `SourceEditor` never diffs its text on update + +`SourceEditor.updateNSViewController` compares language, configuration and highlight +providers. It does **not** compare the text behind its `Binding`. Text is pushed once, +in `makeNSViewController`: + +```swift +case .binding(let binding): + controller.textView.setText(binding.wrappedValue) +``` + +SwiftUI reuses the view controller, so `make` is not called again when the document changes. +Every tab therefore shows the first file opened, until you switch to a file of a *different +language*, which forces the reload. + +**`DocumentTextCoordinator` exists for this and only this.** It captures the controller in +`prepareCoordinator` — the one hook that hands it out — and `CodeEditorView` calls +`reload(text:)` on `document.id` change. It compares against `textView.string` first, so +reloading identical text is free, and `Document.setContent` guards on equality so the +write-back records no edit. + +Do not remove it because it looks redundant — it is not, and the symptom is severe enough to +look like a broken app. If upstream 0.16 diffs text on update, delete the coordinator then. + +`.id(document.id)` on the editor is the fallback if this ever stops working: correct, but it +rebuilds the tree-sitter highlighter on every tab switch and discards scroll and cursor state. + +--- + +--- + ## Quick reference | Question | Answer | @@ -336,4 +367,5 @@ in the UI. | Can I derive isDark from colours? | No. #11. | | Why did the status bar say "1 line selected"? | #17. A caret is not a selection. | | Does `Scripts/build-app.sh` build? | Only since the fix in #16. Run it from clean to check. | +| Why do all my tabs show the same file? | #18. `SourceEditor` does not diff text on update. | | Where do I write "this does not work"? | Here, and [CURRENT_STATE.md](CURRENT_STATE.md). | \ No newline at end of file From 85038e529eb6e1457922ce605a5a56c224e14f8f Mon Sep 17 00:00:00 2001 From: yan Date: Sat, 10 Oct 2026 11:54:20 +0300 Subject: [PATCH 20/22] docs: the editor binding is now a hand-check in the definition of done #15, #16 and #17 are confirmed fixed in the running app, so the caveats about them not being drivable synthetically come out of CURRENT_STATE. The real lesson is worth writing down: none of the three was caught by 68 passing tests. The tab bug lives in upstream's view-update logic, not in anything CodeEditorCore owns, so there was nothing to assert. PROCESS now requires opening two files and switching between them by hand when the editor binding is touched. MEMORY #18 records the same fact. A gate that only runs what is automatable is not a gate. --- PROJECT_PLAN.md | 2 +- docs/CURRENT_STATE.md | 13 ++++--------- docs/MEMORY.md | 6 ++++++ docs/PROCESS.md | 4 ++++ 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index a0bad3f..7b3cf79 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -38,7 +38,7 @@ tracked in GitHub, ordered in [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md). - [ ] Diagnostics render - [ ] No leaks in a 30-minute session - [ ] Unsaved work is guarded on quit — **the one remaining path to data loss**, and it is not an issue yet -- [ ] The redesign has been reviewed by someone who can see it +- [x] The redesign has been reviewed by someone who can see it — it found #15–#17, all since fixed ## Risks diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 9cf0e39..82a6398 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -44,13 +44,12 @@ New items land beside the active document, or at the workspace root. **Editing.** `SourceEditor` from CodeEditSourceEditor 0.15, bound two-way to `Document.content`. Three defects found in visual review of [#14](https://github.com/Yan123-tech/code-editor/issues/14) -were fixed: every tab showed the first file opened -([#15](https://github.com/Yan123-tech/code-editor/issues/15) — upstream does not diff text on -update), a selected folder's chevron did nothing +were fixed and all three are confirmed fixed in the running app: every tab showed the first +file opened ([#15](https://github.com/Yan123-tech/code-editor/issues/15) — upstream does not +diff text on update), a selected folder's chevron did nothing ([#16](https://github.com/Yan123-tech/code-editor/issues/16)), and the gutter drew through the translucent tab strip while rubber-band scrolling -([#17](https://github.com/Yan123-tech/code-editor/issues/17) — fix applied, not yet -confirmed). Tree-sitter highlighting across roughly 25 languages, line-number +([#17](https://github.com/Yan123-tech/code-editor/issues/17)). Tree-sitter highlighting across roughly 25 languages, line-number gutter, bracket-pair emphasis, indentation and reformat-at-column, word wrap, undo stack shared with the document model. Peripherals are configured explicitly: gutter on, minimap off by default with a toggle, folding ribbon and invisibles toggleable, smart quotes and @@ -196,10 +195,6 @@ feel urgent, but it is C interop behind a protocol and nothing else depends on i ### Housekeeping before the next branch -- [#16](https://github.com/Yan123-tech/code-editor/issues/16) and - [#17](https://github.com/Yan123-tech/code-editor/issues/17) are fixed but were not driven in - the running app — synthetic accessibility events do not reach SwiftUI rows, and the gutter - overlap only appears mid-gesture. Both need a second look. - `Scripts/build-app.sh` needs running from a clean checkout before it is believed again. See [MEMORY.md #16](MEMORY.md#16-scriptsbuild-appsh-only-fails-from-a-clean-checkout). diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 2ca18a0..29c1789 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -347,6 +347,11 @@ look like a broken app. If upstream 0.16 diffs text on update, delete the coordi `.id(document.id)` on the editor is the fallback if this ever stops working: correct, but it rebuilds the tree-sitter highlighter on every tab switch and discards scroll and cursor state. +**No test can catch this.** The defect lives in upstream's view-update logic, not in anything +`CodeEditorCore` owns, so the only check is running the app and opening two files. That check +is now part of the definition of done in [PROCESS.md](PROCESS.md). It found this, a chevron +that stopped working, and a gutter bleeding through the tab strip — after 68 passing tests. + --- --- @@ -368,4 +373,5 @@ rebuilds the tree-sitter highlighter on every tab switch and discards scroll and | Why did the status bar say "1 line selected"? | #17. A caret is not a selection. | | Does `Scripts/build-app.sh` build? | Only since the fix in #16. Run it from clean to check. | | Why do all my tabs show the same file? | #18. `SourceEditor` does not diff text on update. | +| What is not covered by `swift test`? | The editor binding. Open two files and switch. See #18. | | Where do I write "this does not work"? | Here, and [CURRENT_STATE.md](CURRENT_STATE.md). | \ No newline at end of file diff --git a/docs/PROCESS.md b/docs/PROCESS.md index 2a0e4ef..f29e13b 100644 --- a/docs/PROCESS.md +++ b/docs/PROCESS.md @@ -87,8 +87,12 @@ Fill in the template. Reviewer needs: what changed, why, how it was verified. - [ ] `swift build` passes - [ ] `swift test` passes - [ ] `swift-format lint --strict` clean +- [ ] If the change touches the editor binding, two documents were opened and switched between + by hand — see [MEMORY.md #18](MEMORY.md). Nothing tests this path: the defect lives in + upstream's view-update logic, so it is only reachable by running the app. - [ ] New public APIs have doc comments explaining the contract, not restating the name - [ ] Behaviour change has a test; bug fix has a test that fails without the fix + — or a written reason there cannot be one, which is a worse excuse than a missing test - [ ] Limitations documented in the PR body or [CURRENT_STATE.md](CURRENT_STATE.md) ## Issues From 528f813b96ac7ce1f4d02493fc5d517fa09a1739 Mon Sep 17 00:00:00 2001 From: yan Date: Sat, 10 Oct 2026 12:13:57 +0300 Subject: [PATCH 21/22] feat(app): guard unsaved work on quit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applicationShouldTerminate returns .terminateCancel while modified documents exist, so quitting no longer discards edits silently — the only remaining path to data loss in the app. The decision is DocumentManager.saveAllModified(save:) in Core, with the save action injected, because saving properly needs UI (an untitled document requires a location panel) while the decision does not. It returns whether every document actually saved, judged by whether it is still modified afterwards — which catches a cancelled panel and a failed write alike, without the caller knowing why. NSAlert rather than a SwiftUI alert here, deliberately: the delegate must answer synchronously and .terminateCancel is what stops the quit, neither of which a view-bound alert can do. A re-entrancy flag lets the second applicationShouldTerminate pass once the user has decided. Five tests cover it, including the dangerous case — a save that does not take must report failure, because the caller is about to discard the buffer. Two of those tests failed while being written and both times the test was wrong, not the code: a fresh Document is clean, and setContent cannot stand in for save() because it sets isModified rather than clearing it. Verified in the running app: a dirty document blocks the quit and Cancel returns to the editor; a clean document quits freely. --- .../CodeEditorApp/Sources/AppDelegate.swift | 83 ++++++++++++++ .../CodeEditorApp/Sources/CodeEditorApp.swift | 6 + .../Sources/Document/Document.swift | 27 +++++ Sources/CodeEditorUI/AppState.swift | 19 ++++ Tests/CodeEditorTests/CodeEditorTests.swift | 105 ++++++++++++++++++ 5 files changed, 240 insertions(+) create mode 100644 Sources/CodeEditorApp/Sources/AppDelegate.swift diff --git a/Sources/CodeEditorApp/Sources/AppDelegate.swift b/Sources/CodeEditorApp/Sources/AppDelegate.swift new file mode 100644 index 0000000..2e969fd --- /dev/null +++ b/Sources/CodeEditorApp/Sources/AppDelegate.swift @@ -0,0 +1,83 @@ +import AppKit +import CodeEditorCore +import CodeEditorUI + +/// Guards unsaved work on quit. +/// +/// Without this the app quits unconditionally and unsaved edits are gone without a word — +/// the only remaining path to data loss in the app. +/// +/// **Why `NSAlert` and not a SwiftUI `.alert`.** `applicationShouldTerminate` must return +/// synchronously, and the answer `terminateCancel` is what stops the quit. A SwiftUI alert +/// bound to view state can do neither: the decision has to be made before the app tears down, +/// with no view tree to attach to. Every other dialog in this app is SwiftUI; this one is not, +/// and it is not a regression of that. +/// +/// The re-entrancy flag matters: after the user answers, the delegate calls `NSApp.terminate` +/// again, which lands back here. Without the flag the prompt would reappear forever. +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + /// Set by the scene once `AppState` exists. Nil means "no state yet", which is treated + /// as "nothing to lose" rather than blocking quit on an unanswerable question. + var appState: AppState? + + private var isResolvingTermination = false + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard !isResolvingTermination, let appState else { return .terminateNow } + + let modified = appState.documentManager.documents.filter(\.isModified) + guard !modified.isEmpty else { return .terminateNow } + + isResolvingTermination = true + Task { await resolveTermination(modified, for: sender) } + return .terminateCancel + } + + // MARK: - Resolution + + private func resolveTermination( + _ modified: [CodeEditorCore.Document], + for sender: NSApplication + ) async { + switch prompt(for: modified) { + case .save: + // A cancelled panel on an untitled document means the work is still there. + // Abandoning the quit is the only way not to throw it away. + if await appState?.saveAllModified() == true { + sender.terminate(nil) + } else { + isResolvingTermination = false + } + case .discard: + sender.terminate(nil) + case .cancel: + isResolvingTermination = false + } + } + + private enum Choice { + case save + case discard + case cancel + } + + private func prompt(for modified: [CodeEditorCore.Document]) -> Choice { + let alert = NSAlert() + if modified.count == 1, let only = modified.first { + alert.messageText = "Save changes to \(only.name)?" + } else { + alert.messageText = "Save changes to \(modified.count) documents?" + } + alert.informativeText = "Your changes will be lost if you don't save them." + alert.addButton(withTitle: "Save") + alert.addButton(withTitle: "Don't Save") + alert.addButton(withTitle: "Cancel") + + switch alert.runModal() { + case .alertFirstButtonReturn: return .save + case .alertSecondButtonReturn: return .discard + default: return .cancel + } + } +} diff --git a/Sources/CodeEditorApp/Sources/CodeEditorApp.swift b/Sources/CodeEditorApp/Sources/CodeEditorApp.swift index bd2d7bb..947e192 100644 --- a/Sources/CodeEditorApp/Sources/CodeEditorApp.swift +++ b/Sources/CodeEditorApp/Sources/CodeEditorApp.swift @@ -6,6 +6,7 @@ import SwiftUI @main struct CodeEditorApp: App { @State private var appState = AppState() + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate var body: some Scene { // A single window: AppState is shared and per-window state would fight it. @@ -14,6 +15,11 @@ struct CodeEditorApp: App { Window("Code Editor", id: "main-window") { MainWindowView(appState: appState) .frame(minWidth: 720, minHeight: 480) + .onAppear { + // The delegate exists before any window does, so it has to be handed + // the state once there is one. Nil means "nothing to lose" meanwhile. + appDelegate.appState = appState + } } .defaultSize(width: 1200, height: 800) .commands { commands } diff --git a/Sources/CodeEditorCore/Sources/Document/Document.swift b/Sources/CodeEditorCore/Sources/Document/Document.swift index ec72a00..a770868 100644 --- a/Sources/CodeEditorCore/Sources/Document/Document.swift +++ b/Sources/CodeEditorCore/Sources/Document/Document.swift @@ -534,6 +534,33 @@ public final class DocumentManager { } } + /// Save every modified document through `save`, and report whether the job finished. + /// + /// Returns false when anything was abandoned — a cancelled location panel, a failed + /// write. The test for "did it take" is whether the document is *still modified* + /// afterwards, which catches both without the caller having to know why. + /// + /// The save action is injected because saving properly needs UI: an untitled document + /// requires a location panel, which Core may not present. Callers own that, Core owns + /// this decision. + /// + /// The caller is about to discard buffers, so false must mean "keep the work" rather + /// than "something went wrong, carry on". + @discardableResult + public func saveAllModified(save: (Document) async -> Void) async -> Bool { + var savedEverything = true + + for document in documents where document.isModified { + await save(document) + // Still modified means the save did not take. + if document.isModified { + savedEverything = false + } + } + + return savedEverything + } + private func add(_ document: Document) { document.delegate = self documents.append(document) diff --git a/Sources/CodeEditorUI/AppState.swift b/Sources/CodeEditorUI/AppState.swift index 02d795b..2d9b59f 100644 --- a/Sources/CodeEditorUI/AppState.swift +++ b/Sources/CodeEditorUI/AppState.swift @@ -211,6 +211,25 @@ public final class AppState { Task { await documentManager.saveAll() } } + /// Save every modified document and report whether the job actually finished. + /// + /// Returns false when anything was abandoned — a cancelled location panel, a failed + /// write. The quit handler treats false as "do not proceed": after this returns, a + /// document that is still modified still holds the user's work. + /// + /// The decision itself belongs to `DocumentManager`, which can be tested without a + /// window; only the saving needs the panels, which is why it is injected. + @discardableResult + public func saveAllModified() async -> Bool { + await documentManager.saveAllModified { document in + if document.isUntitled { + await self.promptForSaveLocation(for: document) + } else { + await self.performSave(document) + } + } + } + /// Prompt for a location, then save. Unsaved documents start as "Untitled N". public func promptForSaveLocation(for document: CodeEditorCore.Document) async { let panel = NSSavePanel() diff --git a/Tests/CodeEditorTests/CodeEditorTests.swift b/Tests/CodeEditorTests/CodeEditorTests.swift index af7cad1..934b3d5 100644 --- a/Tests/CodeEditorTests/CodeEditorTests.swift +++ b/Tests/CodeEditorTests/CodeEditorTests.swift @@ -182,6 +182,111 @@ struct DocumentTests { } } +// MARK: - Save-all-modified + +@MainActor +@Suite("saveAllModified") +struct SaveAllModifiedTests { + /// A document with a real file behind it, so saving is a real save rather than a + /// simulation. `setContent` cannot stand in for one — it *sets* `isModified`, which is + /// the opposite of what saving does. + private func savedDocument(_ content: String) throws -> (Document, URL) { + let url = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("codeeditor-saveall-\(UUID().uuidString).swift") + try "original".write(to: url, atomically: true, encoding: .utf8) + + // Construct with different content first: setContent guards on equality and + // would otherwise no-op, leaving the document clean and the test vacuous. + let document = Document(content: "original", language: .swift, url: url) + document.setContent(content) + return (document, url) + } + + @Test("nothing modified means nothing to do and success") + func cleanDocumentsSucceed() async { + let manager = DocumentManager() + manager.open(Document(content: "untouched", language: .swift)) + var savedAny = false + + let result = await manager.saveAllModified { _ in savedAny = true } + + #expect(result) + #expect(!savedAny) + } + + @Test("a document that saves reports success") + func savesModifiedDocument() async throws { + let (document, url) = try savedDocument("edited") + defer { try? FileManager.default.removeItem(at: url) } + let manager = DocumentManager() + manager.open(document) + + let result = await manager.saveAllModified { try? await $0.save() } + + let onDisk = try String(contentsOf: url, encoding: .utf8) + #expect(result) + #expect(!document.isModified) + #expect(onDisk == "edited") + } + + @Test("a save that does not take reports failure") + func abandonedSaveReportsFailure() async { + let manager = DocumentManager() + let untitled = Document(content: "original", language: .swift) + manager.open(untitled) + untitled.setContent("edited") + + // The save action does nothing — this is what a cancelled location panel looks like + // from here. The caller is about to discard the buffer, so it must be told. + let result = await manager.saveAllModified { _ in } + + #expect(!result) + #expect(untitled.isModified) + } + + @Test("one abandoned save fails the whole job") + func mixedOutcomesReportFailure() async throws { + let (saved, savedURL) = try savedDocument("one edited") + defer { try? FileManager.default.removeItem(at: savedURL) } + + let abandoned = Document(content: "original", language: .swift) + abandoned.setContent("two edited") + + let manager = DocumentManager() + manager.open(saved) + manager.open(abandoned) + + let result = await manager.saveAllModified { document in + // One document saves; the other stands in for a cancelled panel. + guard document === saved else { return } + try? await document.save() + } + + #expect(!result) + #expect(!saved.isModified) + #expect(abandoned.isModified) + } + + @Test("only modified documents are offered") + func visitsOnlyModifiedDocuments() async throws { + let (dirty, dirtyURL) = try savedDocument("dirty") + defer { try? FileManager.default.removeItem(at: dirtyURL) } + let clean = Document(content: "clean", language: .swift) + + let manager = DocumentManager() + manager.open(clean) + manager.open(dirty) + + var visited: [String] = [] + _ = await manager.saveAllModified { document in + visited.append(document.content) + try? await document.save() + } + + #expect(visited == ["dirty"]) + } +} + // MARK: - Document persistence @MainActor From 1593f8bf92e553543a5a54823d0ebf3374087e46 Mon Sep 17 00:00:00 2001 From: yan Date: Sat, 10 Oct 2026 12:14:53 +0300 Subject: [PATCH 22/22] docs: the quit guard is shipped, and it is not the end of data loss CURRENT_STATE moves the data-loss gap out of 'Not working' and into 'Working', but keeps the part that is still true: force-quit and crashes still lose buffers, because nothing persists unsaved content. The roadmap loses its top item and the remaining five renumber. Sizes and test counts re-measured: 73 tests, Core 1478, UI 1904, App 559. --- PROJECT_PLAN.md | 2 +- README.md | 2 ++ docs/ARCHITECTURE.md | 6 ++--- docs/CURRENT_STATE.md | 56 ++++++++++++++++++++----------------------- docs/MEMORY.md | 1 + 5 files changed, 33 insertions(+), 34 deletions(-) diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 7b3cf79..76da60f 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -37,7 +37,7 @@ tracked in GitHub, ordered in [docs/CURRENT_STATE.md](docs/CURRENT_STATE.md). - [ ] Completion works for Swift - [ ] Diagnostics render - [ ] No leaks in a 30-minute session -- [ ] Unsaved work is guarded on quit — **the one remaining path to data loss**, and it is not an issue yet +- [x] Unsaved work is guarded on quit — [#18](https://github.com/Yan123-tech/code-editor/issues/18). Force-quit and crashes still lose buffers - [x] The redesign has been reviewed by someone who can see it — it found #15–#17, all since fixed ## Risks diff --git a/README.md b/README.md index 308a1eb..673bce0 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ an embedded shell in the same window. - **Session restore** — reopens your workspace and tabs on launch. It restores *what you had open*, not your edits: unsaved buffers are not persisted, and untitled documents have no path to replay. +- **Quit guard** — quitting with unsaved changes asks first. Choosing Save abandons the + quit if anything failed to save, so a cancelled dialog never costs you the work. - **Menu commands** — ⌘N, ⌘O, ⇧⌘O, ⌘F, ⌘P, ⌘S, ⌥⌘S, ⌘W, ⌘B, ⌃`, ⇧⌘L for theme. ### Interface notes diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6c001cd..5b244e8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -16,10 +16,10 @@ them. `App` is the only target that owns a window. | Module | Lines | Owns | |---|---|---| -| `CodeEditorCore` | 1451 | `Document`, `DocumentManager`, `TextSelectionManager`, `FileSystemManager`, `FileOutline`, `QuickOpenMatcher`, `QuickOpenIndex`, `SessionStore`, `Metrics`, `Language`, `LineEnding`, `TextSelection` | +| `CodeEditorCore` | 1478 | `Document`, `DocumentManager`, `TextSelectionManager`, `FileSystemManager`, `FileOutline`, `QuickOpenMatcher`, `QuickOpenIndex`, `SessionStore`, `Metrics`, `Language`, `LineEnding`, `TextSelection` | | `CodeEditorLSP` | 1143 | `JSONRPCTransport`, `LSPClient`, wire types | -| `CodeEditorUI` | 1839 | `CodeEditorView`, `FileExplorerView`, `TabBarView`, `StatusBarView`, `EmptyStateView`, `QuickOpenView`, `TerminalView`, `EditorState`, `AppState`, `Theme.editorTheme` | -| `CodeEditorApp` | 484 | `MainWindowView`, `CodeEditorApp`, menu commands | +| `CodeEditorUI` | 1904 | `CodeEditorView`, `FileExplorerView`, `TabBarView`, `StatusBarView`, `EmptyStateView`, `QuickOpenView`, `TerminalView`, `EditorState`, `AppState`, `Theme.editorTheme` | +| `CodeEditorApp` | 559 | `MainWindowView`, `CodeEditorApp`, `AppDelegate` (quit guard), menu commands | | `CodeEditorTerminal` | 245 | `TerminalSession` | | `CodeEditorThemes` | 437 | `Theme`, `Chrome`, `Semantic`, `Typography`, `EditorColor`, `ThemeManager` | diff --git a/docs/CURRENT_STATE.md b/docs/CURRENT_STATE.md index 82a6398..811bd72 100644 --- a/docs/CURRENT_STATE.md +++ b/docs/CURRENT_STATE.md @@ -4,7 +4,7 @@ Last verified against `feature/ui-redesign` on 2026-10-09, Swift 6.4, macOS 27.0 ``` swift build → Build complete -swift test → 68 tests in 16 suites passed +swift test → 73 tests in 17 suites passed swift-format lint --strict → 0 warnings ``` @@ -64,9 +64,13 @@ lines, command history on ↑/↓, stdout and stderr colored separately. **Themes.** Dark, Light, High Contrast. Selection persists to `UserDefaults`, along with font size, tab width, word wrap, minimap, folding ribbon and invisibles. +**Quit guard.** Quitting with modified documents prompts first. Save saves everything and +abandons the quit if any save did not take — a cancelled panel keeps the work; Don't Save +discards; Cancel stays. + **Commands.** ⌘N, ⌘O, ⇧⌘O, ⌘F, ⌘P, ⌘S, ⌥⌘S, ⌘W, ⌘B, ⌃`, ⇧⌘L. All wired; none are empty closures. -**Tests.** 68 swift-testing cases. `FileSystemManager` and `QuickOpenIndex` are tested +**Tests.** 73 swift-testing cases. `FileSystemManager` and `QuickOpenIndex` are tested against in-memory providers, so no test touches the real disk. ## Not working @@ -93,16 +97,15 @@ security-scoped bookmarks. See [MEMORY.md](MEMORY.md). **Untitled documents are not restored.** They have no stable identity across launches. -**Session restore reopens paths, not edits, and nothing guards unsaved work on quit.** The -snapshot stores the workspace root, tab paths and active path — never buffer content. Quit -with unsaved changes and the tab comes back showing what is on disk, with the edits gone and -no prompt on the way out. There is no `applicationShouldTerminate` handler, so the app does -not even ask. Restoring buffers would mean writing them somewhere, and there is no -untitled-document story yet either. The honest version of this feature is "reopens what you -had open", not "resumes your session". +**Session restore reopens paths, not edits.** The snapshot stores the workspace root, tab +paths and active path — never buffer content. Quit with unsaved changes and the tab comes +back showing what is on disk. Restoring buffers would mean writing them somewhere, and there +is no untitled-document story yet either. The honest version of this feature is "reopens what +you had open", not "resumes your session". -This is the only remaining path to data loss in the app, which is why it tops -[Next](#next). +Quitting itself is guarded: a modified document prompts first +([#18](https://github.com/Yan123-tech/code-editor/issues/18)). Losing the buffer to a +force-quit or a crash is still possible — nothing persists unsaved content anywhere. **No multi-cursor.** `TextSelectionManager` models multiple selections and `Document` stores them, but the text view drives a single caret. @@ -132,6 +135,7 @@ one `AppState`, one document set and one sidebar. Multi-window needs per-window | QuickOpenMatcher | 7 | subsequence, ranking, highlight ranges, folding | | QuickOpenIndex | 4 | relative paths, skipped directories, ranking, empty query | | TerminalSession | 4 | history, failed connect, clear | +| saveAllModified | 5 | nothing to do, saved, abandoned, mixed outcomes, only-modified visited | Untested: the view layer in `CodeEditorUI` and `CodeEditorApp` beyond the pure logic extracted into `CodeEditorCore`, and the LSP transport. @@ -141,43 +145,35 @@ extracted into `CodeEditorCore`, and the LSP transport. The redesign ([#14](https://github.com/Yan123-tech/code-editor/issues/14), [PR #13](https://github.com/Yan123-tech/code-editor/pull/13)) changed what is cheap and what is not, so the old ordering no longer holds. Below is what I would pick next and why — -this is a recommendation, not a commitment. The issues are unchanged. - -### 1. Guard unsaved work before anything else - -**No issue yet — worth opening.** Session restore persists paths, never buffer content, and -there is no prompt when the app terminates. Quit with unsaved edits and the tab reopens -showing what is on disk, with the work gone and nothing said. This is the only gap that can -lose data, and the redesign made it more visible by making restore feel like it resumes. +this is a recommendation, not a commitment. -Small: a termination handler that asks when modified documents are open, plus a decision -about whether to persist buffers or not. Data loss outranks features. +The data-loss guard recommended here is done — [#18](https://github.com/Yan123-tech/code-editor/issues#18). -### 2. #1 — LSP completion +### 1. #1 — LSP completion The client already works; nothing calls it. `SourceEditor` takes a `completionDelegate`, so this is wiring rather than design — the least code for the most visible gain, and the project's stated purpose ([IDEA.md](IDEA.md)). The suggestion UI already exists upstream too; the same way ⌘F did not need building. -### 3. #6 — external change detection +### 2. #6 — external change detection A file changed on disk while open is silently overwritten. Also data loss, but narrower: it needs someone else editing the same file. A prompt on save when the modification date moved is enough to start; FSEvents is not required for correctness here. -### 4. #2 then #3 — diagnostics, hover, go-to-definition +### 3. #2 then #3 — diagnostics, hover, go-to-definition Follow #1 naturally: they share the transport and lifecycle that #1 proves. Doing them together means the `TextViewCoordinator` wiring is designed once. -### 5. #4 — settings panel +### 4. #4 — settings panel Smallest of the six. Minimap, folding ribbon, invisibles, wrap, tab width and font size are already toggles in the Format menu and already persisted; the panel is moving existing state into a discoverable surface, not new behaviour. -### 6. #5 — PTY terminal +### 5. #5 — PTY terminal Largest and least user-visible until done. `vim` and `top` are broken today, which makes it feel urgent, but it is C interop behind a protocol and nothing else depends on it. @@ -205,13 +201,13 @@ as a placeholder branch — plan only, no code. | Module | Lines | Files | |---|---|---| -| CodeEditorCore | 1451 | 8 | +| CodeEditorCore | 1478 | 8 | | CodeEditorLSP | 1143 | 3 | -| CodeEditorUI | 1839 | 11 | -| CodeEditorApp | 484 | 2 | +| CodeEditorUI | 1904 | 11 | +| CodeEditorApp | 559 | 2 | | CodeEditorThemes | 437 | 5 | | CodeEditorTerminal | 245 | 1 | -| Tests | 969 | 6 | +| Tests | 1074 | 6 | LSP is the largest module after `CodeEditorUI` and the least used. That is deliberate for now — it is the interesting part — but if the next three issues do not land, it becomes diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 29c1789..8bef402 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -374,4 +374,5 @@ that stopped working, and a gutter bleeding through the tab strip — after 68 p | Does `Scripts/build-app.sh` build? | Only since the fix in #16. Run it from clean to check. | | Why do all my tabs show the same file? | #18. `SourceEditor` does not diff text on update. | | What is not covered by `swift test`? | The editor binding. Open two files and switch. See #18. | +| Does quitting warn me? | Yes, since #18's fix. Force-quit does not, and nothing persists buffers. | | Where do I write "this does not work"? | Here, and [CURRENT_STATE.md](CURRENT_STATE.md). | \ No newline at end of file