Skip to content
6 changes: 5 additions & 1 deletion Docs/Development.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,15 @@ Sources/Workspace/
Support/Shell.swift runs git/gh/bkt through the login shell
Support/GitDirectoryWatcher.swift git run outside the app: watches `.git` for a checkout
Support/WorkingTreeWatcher.swift files written outside the app: FSEvents over the whole tree
Support/GitChangeColors.swift what added, changed and gone look like — tree, gutter and change list
Support/BrandMark.swift real logos (TypeScript, Docker, GitHub…) from SVG
Support/BrandPath.swift the path data behind them — generated, don't edit
Support/AuthorAvatar.swift author pictures: where to find one, cache, disc view
Support/WindowKeyMonitor.swift keys the terminal would otherwise eat (⌃⇥, ⎋)
Support/EscapeKey.swift ⎋ = close, and who keeps ⎋ for themselves
Support/InitialFocus.swift a window that opens with the keyboard in nobody's field
Support/TerminalFocus.swift the keys into a conversation you click, and out of one you leave
Support/ViewerMotion.swift how long a screen takes to arrive, in one place
Support/PullRequestReference.swift finding a `#123` written in a commit message
Support/FileOperations.swift what the Files tab does to disk: create, copy, move, rename, trash
Support/MarkdownPDF.swift the preview as a PDF: measure, render, cut into sheets
Expand All @@ -178,7 +182,7 @@ Sources/Workspace/
PullRequestReviewer.swift who is reviewing, their verdicts, and asking more people
PullRequestBuild.swift the CI runs on the head commit, normalised across hosts
GitHubAccounts.swift per-repo gh account choice + GH_TOKEN injection
GitStatus.swift porcelain status + per-file diff
GitStatus.swift porcelain status + per-file diff, and the verdict a row is coloured by
DefaultBranch.swift which branch the host calls default: gh/bkt, then origin/HEAD
RepositoryCommit.swift the branch's own git log, grouped by day
DiffModel.swift unified diff → side-by-side rows
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,14 @@ missing ones, so you never have to work out which command it wanted.
| --- | --- |
| The board | Open PRs as a table: title, age, activity, reviewers, CI — one row each. |
| Read it all | Details, Diff and Commits tabs. Markdown, images and mentions rendered properly. |
| Threads | Replies nested under what they answer, every author in a colour of their own. |
| Write back | Comment, reply, edit the description, approve, request changes, merge, reject. |
| Threads | Replies nested under what they answer, every author in a colour of their own. A resolved one folds to a single row — author, file and first line, under a green **Resolved** chip — in the conversation and in the diff alike; click it to read the rest. |
Comment thread
necessarylion marked this conversation as resolved.
| Write back | Comment, reply, resolve a thread or open it again, edit the description, approve, request changes, merge, reject. |
| Reviewers | See who approved, add more from a searchable list of the people who can be asked. |
| `@` anyone | Type `@` in any comment box and pick a name from the repo. |
| Builds beside you | CI runs for the head commit in a side panel, failures first. |
| Builds beside you | CI runs for the head commit in a side panel, failures first, kept current on their own while the panel is in front of you. |
| Update from main | One button when the branch has fallen behind. |
| Read it again | One button in the summary bar re-reads the whole request from the host — title, state, diff, conversation, reviewers and builds. |
| Off to the host | The button beside the title opens the pull request in your browser. |

### Code and diffs

Expand All @@ -112,11 +114,12 @@ missing ones, so you never have to work out which command it wanted.
| Feature | What it does |
| --- | --- |
| Real editing | Tree-sitter colours, folding, multiple cursors, find and replace (⌘F), auto-indent. |
| Change markers | What has changed since HEAD, down the edge of the gutter — green where lines were added, blue where they were changed, and a wedge where they were deleted. |
| Change markers | What has changed since HEAD, down the edge of the gutter — green where lines were added, yellow where they were changed, and a red wedge where they were deleted. |
| Language servers | Diagnostics, completions (⌃Space) and ⌘-click to definition for 20+ languages. |
| Go to file (⌘P) | `item controller` finds `app/controllers/item_controller.rb`. Punctuation optional. |
| Search in files | ripgrep, grouped by file, every hit marked in the file you open. |
| A file tree you can use | Drag in and out, copy and paste with Finder, rename in place, duplicate, multi-select, new file or folder. |
| Changes in the tree | A new file is green, a changed one yellow, a conflict red — same colours as the gutter beside it. Folders wear a dot for whatever is inside them. |

### Previews

Expand Down
87 changes: 43 additions & 44 deletions Sources/Workspace/Editor/GutterDiffMarkers.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import AppKit
import CodeEditSourceEditor
import CodeEditTextView
import SwiftUI

/// VS Code's coloured stripe down the line numbers: green where lines were added,
/// blue where they were changed, and a wedge where lines were deleted.
/// yellow where they were changed, and a red wedge where lines were deleted.
///
/// **The gutter is the package's, and it offers nothing to decorate it with.**
/// `TextViewController.gutterView` is internal, `GutterViewDelegate` has one
Expand All @@ -17,9 +16,10 @@ import SwiftUI
/// What makes it hold together rather than drift:
///
/// - **The gutter is findable without the private property.** `GutterView` is a
/// `public` class and `loadView` attaches it with `addFloatingSubview`, so it is
/// a direct subview of the `public` scroll view and can be picked out by type.
/// That is a type check, not a name or an index, and it fails to nothing.
/// `public` class hanging somewhere under the `public` scroll view, so it can be
/// picked out by type — see ``gutterView(in:)`` for where AppKit actually keeps
/// it, which is not where `loadView` appears to put it. That is a type check,
/// not a name or an index, and it fails to nothing.
/// - **Its coordinates are the document's.** The gutter is as tall as the whole
/// file and the package keeps `origin.y` at `textView.frame.origin.y`, so a
/// y inside the gutter *is* a y in the text. Which is why `GutterView` fills its
Expand Down Expand Up @@ -113,7 +113,7 @@ final class GutterDiffMarkers: TextViewCoordinator, @unchecked Sendable {
@MainActor
private func install(in controller: TextViewController) {
guard let scrollView = controller.scrollView,
let gutter = scrollView.subviews.lazy.compactMap({ $0 as? GutterView }).first
let gutter = Self.gutterView(in: scrollView)
else { return }
self.gutter = gutter

Expand Down Expand Up @@ -149,6 +149,36 @@ final class GutterDiffMarkers: TextViewCoordinator, @unchecked Sendable {
}
}

/// The gutter, wherever AppKit has put it.
///
/// **Not `scrollView.subviews`, which is where it looks like it should be.**
/// `loadView` attaches it with `addFloatingSubview`, and that does not add
/// the view to the scroll view: AppKit lifts every floating subview into a
/// private `_NSScrollViewFloatingSubviewsContainerView` of its own and adds
/// *that*. So a scan of the direct subviews finds an `NSClipView` and a
/// container, no `GutterView`, and this whole coordinator quietly installed
/// nothing — which is exactly how the markers came to be written, shipped,
/// and never once drawn.
///
/// Searching breadth-first from the scroll view instead, which is immune to
/// where the container is in the order and to it being renamed or dropped in
/// a later macOS. The clip view is skipped because the document is the text,
/// and the gutter is never inside it — that is the one subtree worth being
/// large.
@MainActor
private static func gutterView(in scrollView: NSScrollView) -> GutterView? {
var queue = scrollView.subviews
var index = 0
while index < queue.count {
let view = queue[index]
index += 1
if let gutter = view as? GutterView { return gutter }
if view is NSClipView { continue }
queue.append(contentsOf: view.subviews)
}
return nil
}

/// Set outright rather than left to an autoresizing mask. The gutter's frame
/// is assigned in several places for several reasons — width for the line
/// count, height for the document, origin for the content insets — and taking
Expand Down Expand Up @@ -245,46 +275,15 @@ private final class GutterDiffMarkerView: NSView {
}
}

/// ``GitChangeColors``, the same three the file tree paints a row in. A
/// stripe beside line 40 and the colour on that file's name in the tree are
/// one claim about one repository, so they are one colour — read the tree,
/// open the file, and the marks carry on saying the same thing.
private func color(for change: GitLineChange) -> NSColor {
switch change {
case .added: GutterDiffColors.added
case .modified: GutterDiffColors.modified
case .deleted: GutterDiffColors.deleted
case .added: GitChangeColors.addedNS
case .modified: GitChangeColors.modifiedNS
case .deleted: GitChangeColors.deletedNS
}
}
}

/// The three stripe colours.
///
/// Added and deleted are the diff viewer's own hues — ``DiffColors`` — rather than
/// a second green and a second red, so the two places in the app that say "this
/// line changed" say it in the same colour. They are lifted out of the dark end of
/// their range on the way, because those values are backgrounds for a whole row of
/// text and a three-point stripe on a dark gutter needs to be the thing that is
/// seen rather than the thing behind it.
///
/// Modified has no such ancestor: the diff viewer never draws a changed line as
/// one thing, it draws the removal beside the addition, so there is no third
/// colour to borrow. Blue is named outright here, as it is in every editor with
/// this feature.
private enum GutterDiffColors {
static let added = stripe(from: DiffColors.addedWord)
static let deleted = stripe(from: DiffColors.removedWord)
static let modified = NSColor(rgb: 0x2E_7A_C6)

/// Same hue, taken up to the saturation and brightness a thin mark needs.
private static func stripe(from color: Color) -> NSColor {
guard let source = NSColor(color).usingColorSpace(.sRGB) else { return NSColor(color) }
var hue: CGFloat = 0
var saturation: CGFloat = 0
var brightness: CGFloat = 0
var alpha: CGFloat = 0
source.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
return NSColor(
hue: hue,
saturation: max(saturation, 0.6),
brightness: max(brightness, 0.62),
alpha: 1
)
}
}
60 changes: 60 additions & 0 deletions Sources/Workspace/Models/GitStatus.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,48 @@
import Foundation

/// What happened to one path, reduced to the handful of verdicts everything
/// outside the change list draws from: the file tree colours a row by this, and
/// the letter the row wears comes from it.
///
/// **The order is the point.** A folder in the tree wears the strongest verdict
/// of anything inside it, and `Comparable` — synthesised from the order these
/// are declared in — is how "strongest" is decided. A folder holding one
/// conflict and twenty edits is a folder with a conflict in it.
enum GitChangeKind: Sendable, Hashable, Comparable {
/// Staged as new, or not in git at all yet. One idea at two stages, and the
/// tree has no room to say which — the change list beside it does.
case added
case renamed
case modified
case deleted
case conflicted

/// The one letter the tree puts at the end of a file's row, the way every
/// editor with this feature does. A conflict is the exception: it is the
/// one that wants noticing rather than reading.
var letter: String {
switch self {
case .added: "A"
case .renamed: "R"
case .modified: "M"
case .deleted: "D"
case .conflicted: "!"
}
}

/// What the row's tooltip says. Plain words, since the letter is the short
/// form and this is the long one.
var label: String {
switch self {
case .added: "Added"
case .renamed: "Renamed"
case .modified: "Modified"
case .deleted: "Deleted"
case .conflicted: "Conflicted"
}
}
}

/// A snapshot of `git status` for a repository.
struct GitStatus: Sendable, Hashable {
struct Change: Sendable, Hashable, Identifiable {
Expand Down Expand Up @@ -67,6 +110,23 @@ struct GitStatus: Sendable, Hashable {
}
}

/// The same verdict as ``label``, in the form the tree and the gutter
/// colour from. Read off the porcelain letter rather than off `label`,
/// so a wording change up there cannot quietly repaint the tree.
///
/// A copy counts as an addition: what the tree is showing is a file
/// that was not there before. A type change counts as a modification,
/// which is what it is — the path stayed, its contents did not.
var kind: GitChangeKind {
if isConflicted { return .conflicted }
switch statusLetter {
case "A", "?", "C": return .added
case "R": return .renamed
case "D": return .deleted
default: return .modified
}
}

var symbol: String {
if isConflicted { return "exclamationmark.triangle.fill" }
switch label {
Expand Down
Loading
Loading