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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ All notable changes to PurePoint are documented here. Format follows [Keep a Cha
## [Unreleased]

### Added
- Projects can be reordered, and every device shows the same order. Each machine's daemon saves the order of its projects. On the Mac, drag a project within its section or use Move Up / Move Down in its menu; on the iPad, use Move Up / Move Down; on the phone, long-press a machine and choose Reorder Projects…. Changes reach every device following that machine right away. `pu projects` lists projects in the same order. New projects are added at the end.
- Paste an image (Cmd+V) into any agent's terminal, or drop files on a remote agent's terminal, and the macOS app uploads it to the agent's machine and types its path there, so remote agents can see screenshots. Uploads are kept in `~/.pu/attachments` on that machine for 7 days.

### Changed
- The daemon protocol is now version 14, for the new `reorder_projects` request. Update PurePoint on every machine and restart its daemon.
- Phones and iPads now pair with full access (agents, terminals, workspace management and Point Guard chat). Point Guard setup shows each device on one line with a single Access menu (Full access, Terminals only, View only, No access) and a Revoke button. Revoked devices are removed instead of lingering as "Revoked", and the redundant "Require re-pair" button is gone. Devices that are already paired keep their current access.
- The daemon protocol is now version 12, for the new `attach_file` request. Update PurePoint on every machine and restart its daemon.
- **Breaking: `scripts/install.sh` installs a Linux release only after verifying who built it.** Linux tarballs are signed with a PurePoint minisign key whose public half is embedded in the script, and releases carry GitHub build provenance attestations. The script checks the signature (with `minisign`, or OpenSSL 3 when minisign isn't installed) and, when the GitHub CLI is installed and signed in, runs `gh attestation verify`. If neither check can run, for example for releases published before signing started, it stops; set `PU_INSTALL_SKIP_VERIFY=1` to install anyway with a warning. A check that fails always stops the install.
Expand Down
67 changes: 67 additions & 0 deletions apps/purepoint-macos/purepoint-macos/Models/ProjectOrder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import Foundation

/// The sidebar's project order. Each machine's daemon saves the order of its projects so
/// every device lists them the same way; these helpers apply it to the rows on screen.
nonisolated enum ProjectOrder {
/// `items` with the one rooted at `root` moved to `index`, or nil when nothing moves.
static func moving<T>(
_ root: String, to index: Int, in items: [T], root rootOf: (T) -> String
) -> [T]? {
guard let from = items.firstIndex(where: { rootOf($0) == root }) else { return nil }
var next = items
let item = next.remove(at: from)
let to = min(max(index, 0), next.count)
guard to != from else { return nil }
next.insert(item, at: to)
return next
}

/// `items` sorted into the order of `roots`. Items `roots` doesn't list keep their relative
/// order after the ones it does.
static func sorted<T>(_ items: [T], by roots: [String], root rootOf: (T) -> String) -> [T] {
var rank: [String: Int] = [:]
for (index, root) in roots.enumerated() where rank[root] == nil {
rank[root] = index
}
return items.enumerated().sorted { left, right in
let l = rank[rootOf(left.element)] ?? Int.max
let r = rank[rootOf(right.element)] ?? Int.max
return l != r ? l < r : left.offset < right.offset
}.map(\.element)
}
}

@MainActor
extension ProjectOrder {
static func moving(_ root: String, to index: Int, in projects: [ProjectState]) -> [ProjectState]? {
moving(root, to: index, in: projects, root: { $0.projectRoot })
}
}

/// Saves one daemon's project order. Moves are sent one at a time, in the order they were
/// made, so the daemon always ends up with the last one; each send carries the full order.
@MainActor
final class ProjectOrderSaver {
private let endpoint: DaemonEndpoint
private var last: Task<Void, Never>?
/// Moves sent but not yet answered. While any are, a project list pushed or fetched from
/// the daemon may predate them, so its order must not replace the one on screen.
private(set) var pending = 0

init(endpoint: DaemonEndpoint) {
self.endpoint = endpoint
}

func save(_ roots: [String], failed: @escaping @MainActor () -> Void) {
let previous = last
let endpoint = endpoint
pending += 1
last = Task { [weak self] in
await previous?.value
let response = try? await endpoint.makeClient().send(.reorderProjects(projectRoots: roots))
self?.pending -= 1
if case .projectOrder = response { return }
failed()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,8 @@ nonisolated enum DaemonRequest: Encodable {
case shutdown
/// The daemon's registered projects; no project root means all of them.
case inventory(kind: String, projectRoot: String? = nil)
/// Save the order of the daemon's registered projects, which every client shows (protocol 14).
case reorderProjects(projectRoots: [String])
// File and git access confined to registered projects (machines.md, protocol 9).
case fsList(path: String, recursive: Bool = false, includeHidden: Bool = true, maxEntries: Int = 20_000)
/// `fs_list` with `directories_only`: the subdirectories of any directory on the machine,
Expand Down Expand Up @@ -621,6 +623,9 @@ nonisolated enum DaemonRequest: Encodable {
try container.encode("inventory", forKey: .key("type"))
try container.encode(kind, forKey: .key("kind"))
try container.encodeIfPresent(projectRoot, forKey: .key("project_root"))
case .reorderProjects(let projectRoots):
try container.encode("reorder_projects", forKey: .key("type"))
try container.encode(projectRoots, forKey: .key("project_roots"))
case .fsList(let path, let recursive, let includeHidden, let maxEntries):
try container.encode("fs_list", forKey: .key("type"))
try container.encode(path, forKey: .key("path"))
Expand Down Expand Up @@ -711,6 +716,8 @@ nonisolated enum DaemonResponse: Decodable {
/// `launchArgs` is nil when the daemon predates machine-level launch args (protocol < 11).
case globalAgentSettingsReport(agentStatusTracking: Bool, launchArgs: [String: String]? = nil)
case inventory(InventoryReportPayload)
/// The registered projects in their saved order.
case projectOrder(projectRoots: [String])
case fsListResult(path: String, entries: [FsEntryPayload], truncated: Bool)
case fsStatResult(FsStatPayload)
case fsReadResult(path: String, data: Data, size: UInt64, modifiedMs: Int64, truncated: Bool)
Expand Down Expand Up @@ -837,6 +844,8 @@ nonisolated enum DaemonResponse: Decodable {
agentStatusTracking: p.agentStatusTracking ?? true, launchArgs: p.launchArgs)
case "inventory":
self = .inventory(try InventoryReportPayload(from: decoder))
case "project_order":
self = .projectOrder(projectRoots: try ProjectOrderPayload(from: decoder).projectRoots)
case "fs_list_result":
let p = try FsListResultPayload(from: decoder)
self = .fsListResult(path: p.path, entries: p.entries, truncated: p.truncated)
Expand Down Expand Up @@ -1253,7 +1262,7 @@ private nonisolated struct DynamicCodingKey: CodingKey {

nonisolated enum DaemonProtocol {
/// The protocol this app speaks. A daemon reporting another version is incompatible.
static let version = 13
static let version = 14
}

nonisolated struct InventoryProjectPayload: Decodable, Sendable, Equatable {
Expand Down Expand Up @@ -1287,6 +1296,14 @@ nonisolated struct InventoryReportPayload: Decodable, Sendable {
}
}

nonisolated struct ProjectOrderPayload: Decodable, Sendable {
let projectRoots: [String]

enum CodingKeys: String, CodingKey {
case projectRoots = "project_roots"
}
}

nonisolated struct FsEntryPayload: Decodable, Sendable, Equatable {
let name: String
let path: String
Expand Down
41 changes: 41 additions & 0 deletions apps/purepoint-macos/purepoint-macos/State/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ final class AppState {
@ObservationIgnored private var lastLocalProjects: Set<String>?
/// Each opened root's canonical path, resolved once rather than on every report.
@ObservationIgnored private var canonicalRoots: [String: String] = [:]
/// The local daemon's project order as last reported.
@ObservationIgnored private var localProjectOrder: [String] = []
@ObservationIgnored private lazy var localOrderSaver = ProjectOrderSaver(endpoint: .local)
#endif
/// Point Guard folders the user also opened as sidebar projects. Other Point Guard
/// folders stay out of the sidebar even though the daemon has them registered.
Expand Down Expand Up @@ -342,6 +345,33 @@ final class AppState {
return false
}

// MARK: - Project Order

/// The projects listed beside `project`: the local ones, or its machine's.
func siblingProjects(of project: ProjectState) -> [ProjectState] {
guard let machineId = project.key.machineId else { return projects }
return machines.first { $0.machine.id == machineId }?.projects ?? []
}

/// Move a project to `index` among its siblings. The order is saved on the machine that
/// owns the project, so every device lists it the same way.
func moveProject(_ project: ProjectState, to index: Int) {
if let machine = machine(for: project) {
machine.moveProject(root: project.projectRoot, to: index)
return
}
#if os(macOS)
guard let next = ProjectOrder.moving(project.projectRoot, to: index, in: projects) else { return }
projects = next
// On success the daemon pushes the new order to every device following it.
localOrderSaver.save(next.map(\.projectRoot)) { [weak self] in
guard let self else { return }
self.daemonError = "Could not save the project order."
self.sortLocalProjects(by: self.localProjectOrder)
}
#endif
}

/// Open the projects this Mac shows. With a root (`--project-root`) only that one; otherwise
/// the local daemon's registered projects, followed from then on, so a project added or
/// removed on any device, or with `pu projects`, is added or removed here too.
Expand Down Expand Up @@ -430,10 +460,21 @@ final class AppState {
guard FileManager.default.fileExists(atPath: root), !isTooBroadProjectRoot(root) else { continue }
openProject(root, resumingSuspendedAgents: resuming)
}
// The daemon's list is in its saved order, which every device shows, unless it may
// predate a move this Mac is still saving.
localProjectOrder = roots
if localOrderSaver.pending == 0 { sortLocalProjects(by: roots) }
lastLocalProjects = registered
#endif
}

#if os(macOS)
private func sortLocalProjects(by roots: [String]) {
let ordered = ProjectOrder.sorted(projects, by: roots, root: { self.canonicalRoot(of: $0.projectRoot) })
if ordered.map(\.projectRoot) != projects.map(\.projectRoot) { projects = ordered }
}
#endif

#if os(macOS)
private func canonicalRoot(of root: String) -> String {
if let known = canonicalRoots[root] { return known }
Expand Down
28 changes: 27 additions & 1 deletion apps/purepoint-macos/purepoint-macos/State/MachineState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ final class MachineState: Identifiable {
/// How often the project list is re-read while the daemon is up, in case a pushed
/// change was missed.
private static let refreshInterval: Duration = .seconds(30)
/// Bumped by each local move, so a project list fetched before it can't undo it.
@ObservationIgnored private var orderRevision = 0
@ObservationIgnored private lazy var orderSaver = ProjectOrderSaver(endpoint: endpoint)

var endpoint: DaemonEndpoint { .machine(machine.id) }
var isConnectedOrConnecting: Bool { state != .disconnected }
Expand Down Expand Up @@ -338,6 +341,7 @@ final class MachineState: Identifiable {
/// Discover the daemon's registered projects (`inventory`, kind projects, all roots).
func refreshProjects() async {
let client = endpoint.makeClient()
let revision = orderRevision
let response: DaemonResponse
do {
response = try await client.send(.inventory(kind: "projects"))
Expand All @@ -351,6 +355,8 @@ final class MachineState: Identifiable {
switch response {
case .inventory(let report):
actionError = nil
// Listed before a move this app made: the next refresh brings the saved order.
guard revision == orderRevision else { return }
setProjects(roots: report.projects.map(\.projectRoot))
case .error(_, let message):
actionError = "Could not list projects on \(machine.name): \(message)"
Expand All @@ -360,7 +366,11 @@ final class MachineState: Identifiable {
}

private func setProjects(roots: [String]) {
let visible = roots.filter { !hiddenRoots.contains($0) }
var visible = roots.filter { !hiddenRoots.contains($0) }
if orderSaver.pending > 0 {
// The list may predate a move still being saved: keep the order on screen.
visible = ProjectOrder.sorted(visible, by: projects.map(\.projectRoot), root: { $0 })
}
let wanted = Set(visible)
var kept: [ProjectState] = []
for project in projects where !wanted.contains(project.projectRoot) {
Expand Down Expand Up @@ -395,6 +405,22 @@ final class MachineState: Identifiable {
}
}

/// Move a project to `index` among this machine's projects and save the order on the
/// machine, so every device lists its projects the same way.
func moveProject(root: String, to index: Int) {
guard let next = ProjectOrder.moving(root, to: index, in: projects) else { return }
projects = next
orderRevision += 1
orderSaver.save(next.map(\.projectRoot)) { [weak self] in
guard let self else { return }
Task {
// Show the order the machine kept.
await self.refreshProjects()
self.actionError = "Could not save the project order on \(self.machine.name)."
}
}
}

private func makeProject(root: String) -> ProjectState {
let endpoint = self.endpoint
let client = endpoint.makeClient()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ enum SidebarMenuAction: Equatable {
case retryLayout
case hideProject
case removeProject
case moveProjectUp
case moveProjectDown
case newAgentInWorktree
case newTerminalInWorktree
}
Expand Down Expand Up @@ -126,6 +128,13 @@ enum SidebarMenus {
if project.isRemote, project.registry?.layoutErrorsByProject[project.projectId] != nil {
entries.append(.action("Retry Layout", .retryLayout))
}
if let siblings = project.appState?.siblingProjects(of: project), siblings.count > 1,
let index = siblings.firstIndex(where: { $0 === project })
{
entries.append(.action("Move Up", .moveProjectUp, isEnabled: index > 0))
entries.append(.action("Move Down", .moveProjectDown, isEnabled: index < siblings.count - 1))
entries.append(.separator)
}
if project.isRemote {
// Hiding is this device's choice; removing changes the machine for every device.
entries.append(.action("Hide on This \(sidebarDevice)\u{2026}", .hideProject))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,32 @@ enum SidebarTree {
return nodes
}

/// Where a dragged project lands when dropped at `childIndex` under `parent` (nil for the
/// top level): the project and its index among its siblings once it's moved, or nil when
/// the drop is outside its own section or would leave it where it is. A project only
/// moves among its own section: the local projects, or one machine's.
static func projectDrop(
projectId: String, parent: SidebarNode?, childIndex: Int, roots: [SidebarNode]
) -> (project: ProjectState, index: Int)? {
let siblings: [SidebarNode]
if let parent {
guard case .machine = parent.kind else { return nil }
siblings = parent.children
} else {
// Local projects lead the top level, ahead of the MACHINES section.
siblings = Array(roots.prefix { if case .project = $0.kind { true } else { false } })
}
let projects = siblings.compactMap { node -> ProjectState? in
if case .project(let project) = node.kind { project } else { nil }
}
guard childIndex >= 0, childIndex <= projects.count,
let from = projects.firstIndex(where: { $0.projectId == projectId })
else { return nil }
// `childIndex` counts the dragged row, which leaves its old place first.
let to = childIndex > from ? childIndex - 1 : childIndex
return to == from ? nil : (projects[from], to)
}

/// Whether every row in the tree holds all the rows it will ever hold. `false` while a
/// project has yet to read its snapshot and a machine has yet to answer its inventory,
/// which is how the sidebar looks for the first moments after a relaunch or a machine
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ extension SidebarOutlineViewController {
showConfirmation(.removeProject(project)) {
self.onRemoveProject?(project)
}
case (.moveProjectUp, .project(let project)), (.moveProjectDown, .project(let project)):
guard let appState = project.appState,
let index = appState.siblingProjects(of: project).firstIndex(where: { $0 === project })
else { return }
appState.moveProject(project, to: index + (action == .moveProjectUp ? -1 : 1))
case (.newAgentInWorktree, .worktree(let worktree)):
guard let project = findProject(forWorktreeId: worktree.id) else { return }
onShowCommandPalette?(project, .worktree(worktree.id), false)
Expand Down
Loading
Loading