Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ jobs:
- name: Test Web Remote browser input
run: node --test scripts/test-web-remote-*.mjs

- name: Test Git and persistence regressions
run: node --test scripts/test-git-review.mjs scripts/test-persistence-recovery.mjs

- name: Test Glint
run: |
set -euo pipefail
Expand Down
32 changes: 17 additions & 15 deletions Glint/Git/GitDiff.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ struct GitFileChange: Identifiable, Equatable {

extension GitService {

/// Files changed under `scope`, sorted by path. Never throws — a non-repo or
/// bad base just yields an empty list (the UI shows "no changes").
/// Return files changed in `repo` under `scope`, sorted by literal path.
/// Parameters: repository directory and comparison scope. Returns an empty
/// list for a non-repository or invalid base; never throws.
func changedFiles(repo: String, scope: DiffScope) async -> [GitFileChange] {
switch scope {
case .workingTree:
Expand All @@ -41,9 +42,9 @@ extension GitService {
// tier it would SIGTERM, drop to `u.ok == false`, and silently
// omit every untracked file from the Review list (worse than
// truncation: the entries appear to not exist at all).
async let names = git(["diff", "HEAD", "--name-status"], cwd: repo,
async let names = git(["diff", "HEAD", "--no-renames", "--name-status", "-z"], cwd: repo,
allowFailure: true, timeout: .read)
async let nums = git(["diff", "HEAD", "--numstat"], cwd: repo,
async let nums = git(["diff", "HEAD", "--no-renames", "--numstat", "-z"], cwd: repo,
allowFailure: true, timeout: .read)
async let untrk = git(["ls-files", "--others", "--exclude-standard", "-z"], cwd: repo,
allowFailure: true, timeout: .read)
Expand All @@ -62,9 +63,9 @@ extension GitService {
return withMtime(map.values.sorted { $0.path < $1.path }, repo: repo)

case .branch(let base):
async let names = git(["diff", "\(base)...HEAD", "--name-status"], cwd: repo,
async let names = git(["diff", "\(base)...HEAD", "--no-renames", "--name-status", "-z"], cwd: repo,
allowFailure: true, timeout: .read)
async let nums = git(["diff", "\(base)...HEAD", "--numstat"], cwd: repo,
async let nums = git(["diff", "\(base)...HEAD", "--no-renames", "--numstat", "-z"], cwd: repo,
allowFailure: true, timeout: .read)
let map = Self.mergeNameNumstat(
nameStatus: (try? await names)?.stdout ?? "",
Expand Down Expand Up @@ -129,15 +130,16 @@ extension GitService {

// MARK: parsing

/// Merge `--name-status` (kind per path) with `--numstat` (line counts). No
/// `-M`: a rename appears as a delete + add pair, which both commands agree
/// on, so keying by path is consistent.
/// Merge literal paths from NUL-delimited Git output. Parameters:
/// `nameStatus` from `--name-status -z --no-renames` and `numstat` from
/// `--numstat -z --no-renames`. Returns changes keyed by their actual paths.
/// Explicitly disabling renames keeps delete/add paths aligned even when
/// the repository enables rename detection; NUL avoids Git's quoted paths.
static func mergeNameNumstat(nameStatus: String, numstat: String) -> [String: GitFileChange] {
var kinds: [String: GitFileChange.Kind] = [:]
for line in nameStatus.split(separator: "\n", omittingEmptySubsequences: true) {
let parts = line.split(separator: "\t")
guard parts.count >= 2 else { continue }
let code = String(parts[0]); let path = String(parts[parts.count - 1])
var names = nameStatus.split(separator: "\0", omittingEmptySubsequences: true).makeIterator()
while let code = names.next(), let rawPath = names.next() {
let path = String(rawPath)
switch code.first {
case "A": kinds[path] = .added
case "D": kinds[path] = .deleted
Expand All @@ -146,15 +148,15 @@ extension GitService {
}
}
var out: [String: GitFileChange] = [:]
for line in numstat.split(separator: "\n", omittingEmptySubsequences: true) {
for line in numstat.split(separator: "\0", omittingEmptySubsequences: true) {
let parts = line.split(separator: "\t", maxSplits: 2, omittingEmptySubsequences: false)
guard parts.count == 3 else { continue }
let a = String(parts[0]); let d = String(parts[1]); let path = String(parts[2])
let binary = (a == "-" || d == "-") // numstat marks binary files with "-"
out[path] = GitFileChange(path: path, kind: kinds[path] ?? .modified,
additions: Int(a) ?? 0, deletions: Int(d) ?? 0, isBinary: binary)
}
// Mode-only / rename entries can appear in name-status but not numstat.
// Mode-only entries can appear in name-status but not numstat.
for (path, kind) in kinds where out[path] == nil {
out[path] = GitFileChange(path: path, kind: kind, additions: 0, deletions: 0, isBinary: false)
}
Expand Down
8 changes: 5 additions & 3 deletions Glint/Git/GitService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -489,14 +489,15 @@ struct SSHGitRunner: GitRunner {
}

/// Quote `cwd` for inclusion as `git -C <quoted>` in the remote command.
/// Parameter: remote working directory. Returns one re-parse-safe shell word.
/// Single-quotes everything EXCEPT a leading `~` (or `~user`) segment,
/// which is left unquoted so the remote login shell performs tilde
/// expansion. Bash's default `\w` PS1 prints `~/proj` for the user's home
/// — keeping that working is the whole point of the carve-out.
///
/// Examples (assuming remote `$HOME=/home/deploy`):
/// `~/proj` → `~'/proj'` → `/home/deploy/proj`
/// `~admin/proj` → `~admin'/proj'` → admin's `$HOME` + `/proj`
/// `~/proj` → `~/'proj'` → `/home/deploy/proj`
/// `~admin/proj` → `~admin/'proj'` → admin's `$HOME` + `/proj`
/// `~` → `~` → `/home/deploy`
/// `/abs/path` → `'/abs/path'` → literal
/// `/srv/has space` → `'/srv/has space'` → literal (space safe in quotes)
Expand Down Expand Up @@ -525,7 +526,8 @@ struct SSHGitRunner: GitRunner {
}
guard safe else { return posixShellQuoted(cwd) }
guard let slash else { return tildePart }
return tildePart + posixShellQuoted(String(cwd[slash...]))
// POSIX shells need the first slash unquoted to terminate the tilde prefix.
return tildePart + "/" + posixShellQuoted(String(cwd[cwd.index(after: slash)...]))
}

}
Expand Down
52 changes: 48 additions & 4 deletions Glint/Resources/WebRemote/web-remote.js
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ function websocketURL() {
return `${scheme}//${location.hostname}:${httpPort + 1}/control`;
}

/** Open a fresh WebSocket session; takes no arguments and returns nothing. */
function connect() {
clearTimeout(reconnectTimer);
if (socket) {
Expand All @@ -395,6 +396,7 @@ function connect() {
const currentSocket = socket;
socket.binaryType = "arraybuffer";
socket.addEventListener("open", () => {
if (socket !== currentSocket) return;
lastServerMessageAt = Date.now();
reconnectDelay = 500;
// The server issues an auth-challenge as soon as the socket opens; we wait
Expand All @@ -406,7 +408,7 @@ function connect() {
lastServerMessageAt = Date.now();
const data = event.data;
if (typeof data === "string") {
handleMessage(data); // plaintext: auth-challenge / handshake error
handlePlaintextFrame(data);
} else {
onEncryptedFrame(data); // binary: encrypted frame
}
Expand All @@ -420,7 +422,36 @@ function connect() {
reconnectTimer = setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 1.8, 8000);
});
socket.addEventListener("error", () => setStatus("error", t("unable_connect")));
socket.addEventListener("error", () => {
if (socket !== currentSocket) return;
setStatus("error", t("unable_connect"));
});
}

/** Validate a plaintext handshake frame; takes raw JSON and returns nothing. */
function handlePlaintextFrame(raw) {
if (encrypted || authenticated) {
socket?.close();
return;
}
let message;
try {
message = JSON.parse(raw);
} catch {
socket?.close();
return;
}
const validChallenge = message?.type === "auth-challenge"
&& pendingChallenge === null
&& typeof message.challenge === "string"
&& /^[A-Za-z0-9+/]{43}=$/.test(message.challenge);
const validError = message?.type === "error"
&& ["unauthorized", "bad-request"].includes(message.code);
if (!validChallenge && !validError) {
socket?.close();
return;
}
handleMessage(raw);
}

function reconnectIfStale() {
Expand Down Expand Up @@ -465,8 +496,9 @@ function deriveSession(accessKey, challengeB64) {
return encodeBase64(proof);
}

/// Send the proof once both the access key and the challenge are known.
/** Send the handshake proof when ready; takes no arguments and returns nothing. */
function tryAuthenticate() {
if (encrypted || authenticated) return;
if (!token || !pendingChallenge) return;
if (!socket || socket.readyState !== WebSocket.OPEN) return;
const proof = deriveSession(token, pendingChallenge);
Expand Down Expand Up @@ -511,16 +543,19 @@ function onEncryptedFrame(buffer) {
handleMessage(json);
}

/** Send a JSON command without plaintext fallback; takes a message and returns nothing. */
function send(message) {
if (!socket || socket.readyState !== WebSocket.OPEN) return;
const json = JSON.stringify(message);
if (encrypted && c2sKey) {
socket.send(encryptFrame(json));
} else {
} else if (!encrypted && !authenticated && message.type === "authenticate"
&& pendingChallenge && c2sKey && s2cKey) {
socket.send(json);
}
}

/** Dispatch validated protocol JSON; takes a JSON string and returns nothing. */
function handleMessage(raw) {
let message;
try {
Expand All @@ -530,6 +565,10 @@ function handleMessage(raw) {
}
switch (message.type) {
case "auth-challenge":
if (encrypted) {
socket?.close();
return;
}
pendingChallenge = message.challenge;
tryAuthenticate();
break;
Expand Down Expand Up @@ -755,8 +794,13 @@ function applyTheme(theme) {
};
}

/** Update protocol error UI; takes a server error code and returns nothing. */
function handleError(code) {
if (code === "unauthorized") {
if (encrypted) {
socket?.close();
return;
}
authenticated = false;
updateFilesButton();
token = "";
Expand Down
27 changes: 23 additions & 4 deletions Glint/Workspace/Persistence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ enum Persistence {
/// `Pane`. Returns re-serialized JSON if a bad pane was removed, nil if
/// nothing changed or the structure is unrecognizable (so the caller only
/// retries when there's something to retry with). Lets one bad pane cost
/// only that pane instead of the whole file.
/// only that pane instead of the whole file. Parameter data is saved JSON;
/// the returned repair also removes stale layout/focus references.
static func stripBadPanes(from data: Data) -> Data? {
guard var root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
var workspaces = root["workspaces"] as? [[String: Any]] else { return nil }
Expand All @@ -173,7 +174,17 @@ enum Persistence {
}
j += 2
}
if kept.count != panes.count { workspaces[i]["panes"] = kept }
if kept.count != panes.count {
workspaces[i]["panes"] = kept
// Workspace's decoder repairs the split trees after pruning;
// persist those repaired references along with the registry.
if let blob = SafeJSON.data(workspaces[i]),
let workspace = try? pdecoder.decode(Workspace.self, from: blob),
let encoded = try? JSONEncoder().encode(workspace),
let repaired = (try? JSONSerialization.jsonObject(with: encoded)) as? [String: Any] {
workspaces[i] = repaired
}
}
}
guard changed else { return nil }
root["workspaces"] = workspaces
Expand All @@ -186,25 +197,33 @@ enum Persistence {
/// letting one bad workspace cost only that workspace instead of the whole
/// file. Returns nil if nothing was dropped, if every workspace is bad
/// (abstaining lets the caller fall back to a fresh state and preserve the
/// original on disk), or if the structure is unrecognizable.
/// original on disk), or if the structure is unrecognizable. Parameter data
/// is saved JSON; a returned repair selects an existing active survivor.
static func stripBadWorkspaces(from data: Data) -> Data? {
guard var root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let workspaces = root["workspaces"] as? [Any] else { return nil }
let wdecoder = JSONDecoder()
var kept: [Any] = []
var decoded: [Workspace] = []
var dropped = 0
for ws in workspaces {
// SafeJSON, not bare data(withJSONObject:): a stray non-JSON leaf
// or non-finite number throws an NSException (uncatchable by try?).
let blob = SafeJSON.data(ws) ?? Data()
if (try? wdecoder.decode(Workspace.self, from: blob)) != nil {
if let workspace = try? wdecoder.decode(Workspace.self, from: blob) {
kept.append(ws)
decoded.append(workspace)
} else {
dropped += 1
}
}
guard dropped > 0, !kept.isEmpty else { return nil }
root["workspaces"] = kept
if let selected = root["selectedWorkspaceID"] as? String,
let id = UUID(uuidString: selected),
!decoded.contains(where: { $0.id == id }) {
root["selectedWorkspaceID"] = (decoded.first(where: { !$0.archived }) ?? decoded[0]).id.uuidString
}
NSLog("[glint] \(fileName): dropping \(dropped) undecodable workspace(s), keeping \(kept.count)")
return SafeJSON.data(root)
}
Expand Down
53 changes: 53 additions & 0 deletions Glint/Workspace/WorkspaceStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,25 @@ extension SplitNode {
case .split(_, _, let a, let b): return a.leaves + b.leaves
}
}

/// Prune missing panes; validIDs contains registered pane IDs; returns the surviving tree or nil.
func retainingPanes(_ validIDs: Set<PaneID>) -> SplitNode? {
switch self {
case .leaf(let id):
return validIDs.contains(id) ? self : nil
case .split(let direction, let ratio, let a, let b):
let keptA = a.retainingPanes(validIDs)
let keptB = b.retainingPanes(validIDs)
switch (keptA, keptB) {
case (.some(let a), .some(let b)):
return .split(direction: direction, ratio: ratio, a: a, b: b)
case (.some(let node), .none), (.none, .some(let node)):
return node
case (.none, .none):
return nil
}
}
}
}

/// Where a workspace's shell/agent/git actually run, and what repo/branch it's
Expand Down Expand Up @@ -344,6 +363,7 @@ struct Workspace: Identifiable, Codable {
self.source = source
}

/// Decode a workspace and repair dangling pane references; decoder supplies saved data; returns a restored workspace or throws.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decode(UUID.self, forKey: .id)
Expand Down Expand Up @@ -386,6 +406,33 @@ struct Workspace: Identifiable, Codable {
self.selectedTabID = TabID(value: 0)
self.nextTabSeq = 1
}

let validIDs = Set(panes.keys)
for index in self.tabs.indices {
if let root = self.tabs[index].root.retainingPanes(validIDs) {
self.tabs[index].root = root
if !root.leaves.contains(self.tabs[index].focusedPane) {
self.tabs[index].focusedPane = root.leaves[0]
}
} else {
// An entirely damaged tab still needs a registered terminal;
// preserve its identity/name instead of restoring a blank tab.
let maxID = panes.keys.map(\.value).max()
guard self.nextPaneSeq < UInt32.max,
maxID.map({ $0 < UInt32.max - 1 }) ?? true else {
throw DecodingError.dataCorruptedError(forKey: .nextPaneSeq, in: c,
debugDescription: "Pane identifier sequence is exhausted")
}
if let maxID {
self.nextPaneSeq = Swift.max(self.nextPaneSeq, maxID + 1)
}
let id = PaneID(value: self.nextPaneSeq)
self.nextPaneSeq += 1
self.panes[id] = Pane(id: id, title: "zsh")
self.tabs[index].root = .leaf(id)
self.tabs[index].focusedPane = id
}
}
}

// Explicit because CodingKeys carries the legacy `root`/`focusedPane`
Expand Down Expand Up @@ -433,6 +480,7 @@ struct PersistedState: Codable {
case version, workspaces, selectedWorkspaceID, sidebarCollapsed
}

/// Decode saved state; decoder supplies the snapshot; returns state with a valid workspace selection or throws.
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
// Files written before versioning have no `version` key — they are
Expand All @@ -441,6 +489,11 @@ struct PersistedState: Codable {
self.workspaces = try c.decode([Workspace].self, forKey: .workspaces)
self.selectedWorkspaceID = try c.decodeIfPresent(UUID.self, forKey: .selectedWorkspaceID)
self.sidebarCollapsed = try c.decodeIfPresent(Bool.self, forKey: .sidebarCollapsed) ?? false
if let selectedWorkspaceID,
!workspaces.contains(where: { $0.id == selectedWorkspaceID }) {
self.selectedWorkspaceID = workspaces.first(where: { !$0.archived })?.id
?? workspaces.first?.id
}
}

static var fresh: PersistedState {
Expand Down
Loading
Loading