Canvas: simplify show + report status
This commit is contained in:
@@ -18,13 +18,38 @@ final class CanvasManager {
|
||||
}()
|
||||
|
||||
func show(sessionKey: String, path: String? = nil, placement: CanvasPlacement? = nil) throws -> String {
|
||||
try self.showDetailed(sessionKey: sessionKey, target: path, placement: placement).directory
|
||||
}
|
||||
|
||||
func showDetailed(sessionKey: String, target: String? = nil, placement: CanvasPlacement? = nil) throws -> CanvasShowResult {
|
||||
let anchorProvider = self.defaultAnchorProvider ?? Self.mouseAnchorProvider
|
||||
let session = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedTarget = target?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.nonEmpty
|
||||
|
||||
let isWebTarget = Self.isWebTarget(normalizedTarget)
|
||||
|
||||
if let controller = self.panelController, self.panelSessionKey == session {
|
||||
controller.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||
controller.applyPreferredPlacement(placement)
|
||||
controller.goto(path: path ?? "/")
|
||||
return controller.directoryPath
|
||||
|
||||
// Existing session: only navigate when an explicit target was provided.
|
||||
if let normalizedTarget {
|
||||
controller.goto(path: normalizedTarget)
|
||||
return self.makeShowResult(
|
||||
directory: controller.directoryPath,
|
||||
target: target,
|
||||
effectiveTarget: normalizedTarget,
|
||||
isWebTarget: isWebTarget)
|
||||
}
|
||||
|
||||
return CanvasShowResult(
|
||||
directory: controller.directoryPath,
|
||||
target: target,
|
||||
effectiveTarget: nil,
|
||||
status: .shown,
|
||||
url: nil)
|
||||
}
|
||||
|
||||
self.panelController?.close()
|
||||
@@ -39,8 +64,16 @@ final class CanvasManager {
|
||||
self.panelController = controller
|
||||
self.panelSessionKey = session
|
||||
controller.applyPreferredPlacement(placement)
|
||||
controller.showCanvas(path: path ?? "/")
|
||||
return controller.directoryPath
|
||||
|
||||
// New session: default to "/" so the user sees either the welcome page or `index.html`.
|
||||
let effectiveTarget = normalizedTarget ?? "/"
|
||||
controller.showCanvas(path: effectiveTarget)
|
||||
|
||||
return self.makeShowResult(
|
||||
directory: controller.directoryPath,
|
||||
target: target,
|
||||
effectiveTarget: effectiveTarget,
|
||||
isWebTarget: isWebTarget)
|
||||
}
|
||||
|
||||
func hide(sessionKey: String) {
|
||||
@@ -53,10 +86,6 @@ final class CanvasManager {
|
||||
self.panelController?.hideCanvas()
|
||||
}
|
||||
|
||||
func goto(sessionKey: String, path: String, placement: CanvasPlacement? = nil) throws {
|
||||
_ = try self.show(sessionKey: sessionKey, path: path, placement: placement)
|
||||
}
|
||||
|
||||
func eval(sessionKey: String, javaScript: String) async throws -> String {
|
||||
_ = try self.show(sessionKey: sessionKey, path: nil)
|
||||
guard let controller = self.panelController else { return "" }
|
||||
@@ -79,4 +108,89 @@ final class CanvasManager {
|
||||
}
|
||||
|
||||
// placement interpretation is handled by the window controller.
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private static func isWebTarget(_ target: String?) -> Bool {
|
||||
guard let target, let url = URL(string: target), let scheme = url.scheme?.lowercased() else { return false }
|
||||
return scheme == "https" || scheme == "http"
|
||||
}
|
||||
|
||||
private func makeShowResult(
|
||||
directory: String,
|
||||
target: String?,
|
||||
effectiveTarget: String,
|
||||
isWebTarget: Bool) -> CanvasShowResult
|
||||
{
|
||||
if isWebTarget, let url = URL(string: effectiveTarget) {
|
||||
return CanvasShowResult(
|
||||
directory: directory,
|
||||
target: target,
|
||||
effectiveTarget: effectiveTarget,
|
||||
status: .web,
|
||||
url: url.absoluteString)
|
||||
}
|
||||
|
||||
let sessionDir = URL(fileURLWithPath: directory)
|
||||
let status = Self.localStatus(sessionDir: sessionDir, target: effectiveTarget)
|
||||
let host = sessionDir.lastPathComponent
|
||||
let canvasURL = CanvasScheme.makeURL(session: host, path: effectiveTarget)?.absoluteString
|
||||
return CanvasShowResult(
|
||||
directory: directory,
|
||||
target: target,
|
||||
effectiveTarget: effectiveTarget,
|
||||
status: status,
|
||||
url: canvasURL)
|
||||
}
|
||||
|
||||
private static func localStatus(sessionDir: URL, target: String) -> CanvasShowStatus {
|
||||
let fm = FileManager.default
|
||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let withoutQuery = trimmed.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? trimmed
|
||||
var path = withoutQuery
|
||||
if path.hasPrefix("/") { path.removeFirst() }
|
||||
path = path.removingPercentEncoding ?? path
|
||||
|
||||
// Root special-case: welcome page when no index exists.
|
||||
if path.isEmpty {
|
||||
let a = sessionDir.appendingPathComponent("index.html", isDirectory: false)
|
||||
let b = sessionDir.appendingPathComponent("index.htm", isDirectory: false)
|
||||
if fm.fileExists(atPath: a.path) || fm.fileExists(atPath: b.path) { return .ok }
|
||||
return .welcome
|
||||
}
|
||||
|
||||
// Direct file or directory.
|
||||
var candidate = sessionDir.appendingPathComponent(path, isDirectory: false)
|
||||
var isDir: ObjCBool = false
|
||||
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir) {
|
||||
if isDir.boolValue {
|
||||
return Self.indexExists(in: candidate) ? .ok : .notFound
|
||||
}
|
||||
return .ok
|
||||
}
|
||||
|
||||
// Directory index behavior ("/yolo" -> "yolo/index.html") if directory exists.
|
||||
if !path.isEmpty, !path.hasSuffix("/") {
|
||||
candidate = sessionDir.appendingPathComponent(path, isDirectory: true)
|
||||
if fm.fileExists(atPath: candidate.path, isDirectory: &isDir), isDir.boolValue {
|
||||
return Self.indexExists(in: candidate) ? .ok : .notFound
|
||||
}
|
||||
}
|
||||
|
||||
return .notFound
|
||||
}
|
||||
|
||||
private static func indexExists(in dir: URL) -> Bool {
|
||||
let fm = FileManager.default
|
||||
let a = dir.appendingPathComponent("index.html", isDirectory: false)
|
||||
if fm.fileExists(atPath: a.path) { return true }
|
||||
let b = dir.appendingPathComponent("index.htm", isDirectory: false)
|
||||
return fm.fileExists(atPath: b.path)
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var nonEmpty: String? {
|
||||
isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,8 +101,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
self.presentAnchoredPanel(anchorProvider: anchorProvider)
|
||||
if let path {
|
||||
self.goto(path: path)
|
||||
} else {
|
||||
self.goto(path: "/")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -112,8 +110,6 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if let path {
|
||||
self.goto(path: path)
|
||||
} else {
|
||||
self.goto(path: "/")
|
||||
}
|
||||
self.onVisibilityChanged?(true)
|
||||
}
|
||||
|
||||
@@ -61,9 +61,6 @@ enum ControlRequestHandler {
|
||||
case let .canvasHide(session):
|
||||
return await self.handleCanvasHide(session: session)
|
||||
|
||||
case let .canvasGoto(session, path, placement):
|
||||
return await self.handleCanvasGoto(session: session, path: path, placement: placement)
|
||||
|
||||
case let .canvasEval(session, javaScript):
|
||||
return await self.handleCanvasEval(session: session, javaScript: javaScript)
|
||||
|
||||
@@ -196,10 +193,11 @@ enum ControlRequestHandler {
|
||||
{
|
||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||
do {
|
||||
let dir = try await MainActor.run {
|
||||
try CanvasManager.shared.show(sessionKey: session, path: path, placement: placement)
|
||||
let res = try await MainActor.run {
|
||||
try CanvasManager.shared.showDetailed(sessionKey: session, target: path, placement: placement)
|
||||
}
|
||||
return Response(ok: true, message: dir)
|
||||
let payload = try? JSONEncoder().encode(res)
|
||||
return Response(ok: true, message: res.directory, payload: payload)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
@@ -210,18 +208,6 @@ enum ControlRequestHandler {
|
||||
return Response(ok: true)
|
||||
}
|
||||
|
||||
private static func handleCanvasGoto(session: String, path: String, placement: CanvasPlacement?) async -> Response {
|
||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||
do {
|
||||
try await MainActor.run {
|
||||
try CanvasManager.shared.goto(sessionKey: session, path: path, placement: placement)
|
||||
}
|
||||
return Response(ok: true)
|
||||
} catch {
|
||||
return Response(ok: false, message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleCanvasEval(session: String, javaScript: String) async -> Response {
|
||||
guard self.canvasEnabled() else { return Response(ok: false, message: "Canvas disabled by user") }
|
||||
do {
|
||||
|
||||
@@ -830,7 +830,7 @@ extension DebugSettings {
|
||||
"""
|
||||
try html.write(to: url, atomically: true, encoding: .utf8)
|
||||
self.canvasStatus = "wrote: \(url.path)"
|
||||
try CanvasManager.shared.goto(sessionKey: session.isEmpty ? "main" : session, path: "/")
|
||||
_ = try CanvasManager.shared.show(sessionKey: session.isEmpty ? "main" : session, path: "/")
|
||||
} catch {
|
||||
self.canvasError = error.localizedDescription
|
||||
}
|
||||
|
||||
@@ -243,10 +243,10 @@ struct ClawdisCLI {
|
||||
switch sub {
|
||||
case "show":
|
||||
var session = "main"
|
||||
var path: String?
|
||||
let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path)
|
||||
var target: String?
|
||||
let placement = self.parseCanvasPlacement(args: &args, session: &session, target: &target)
|
||||
return ParsedCLIRequest(
|
||||
request: .canvasShow(session: session, path: path, placement: placement),
|
||||
request: .canvasShow(session: session, path: target, placement: placement),
|
||||
kind: .generic)
|
||||
case "hide":
|
||||
var session = "main"
|
||||
@@ -258,14 +258,6 @@ struct ClawdisCLI {
|
||||
}
|
||||
}
|
||||
return ParsedCLIRequest(request: .canvasHide(session: session), kind: .generic)
|
||||
case "goto":
|
||||
var session = "main"
|
||||
var path: String?
|
||||
let placement = self.parseCanvasPlacement(args: &args, session: &session, path: &path)
|
||||
guard let path else { throw CLIError.help }
|
||||
return ParsedCLIRequest(
|
||||
request: .canvasGoto(session: session, path: path, placement: placement),
|
||||
kind: .generic)
|
||||
case "eval":
|
||||
var session = "main"
|
||||
var js: String?
|
||||
@@ -359,7 +351,7 @@ struct ClawdisCLI {
|
||||
private static func parseCanvasPlacement(
|
||||
args: inout [String],
|
||||
session: inout String,
|
||||
path: inout String?) -> CanvasPlacement?
|
||||
target: inout String?) -> CanvasPlacement?
|
||||
{
|
||||
var x: Double?
|
||||
var y: Double?
|
||||
@@ -369,7 +361,7 @@ struct ClawdisCLI {
|
||||
let arg = args.removeFirst()
|
||||
switch arg {
|
||||
case "--session": session = args.popFirst() ?? session
|
||||
case "--path": path = args.popFirst()
|
||||
case "--target", "--path": target = args.popFirst()
|
||||
case "--x": x = args.popFirst().flatMap(Double.init)
|
||||
case "--y": y = args.popFirst().flatMap(Double.init)
|
||||
case "--width": width = args.popFirst().flatMap(Double.init)
|
||||
@@ -388,6 +380,19 @@ struct ClawdisCLI {
|
||||
return
|
||||
}
|
||||
|
||||
if case .canvasShow = parsed.request {
|
||||
if let message = response.message, !message.isEmpty {
|
||||
FileHandle.standardOutput.write(Data((message + "\n").utf8))
|
||||
}
|
||||
if let payload = response.payload, let info = try? JSONDecoder().decode(CanvasShowResult.self, from: payload) {
|
||||
FileHandle.standardOutput.write(Data(("STATUS:\(info.status.rawValue)\n").utf8))
|
||||
if let url = info.url, !url.isEmpty {
|
||||
FileHandle.standardOutput.write(Data(("URL:\(url)\n").utf8))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch parsed.kind {
|
||||
case .generic:
|
||||
if let payload = response.payload, let text = String(data: payload, encoding: .utf8), !text.isEmpty {
|
||||
@@ -468,11 +473,9 @@ struct ClawdisCLI {
|
||||
clawdis-mac node invoke --node <id> --command <name> [--params-json <json>]
|
||||
|
||||
Canvas:
|
||||
clawdis-mac canvas show [--session <key>] [--path </...>]
|
||||
clawdis-mac canvas show [--session <key>] [--target </...|https://...>]
|
||||
[--x <screenX> --y <screenY>] [--width <w> --height <h>]
|
||||
clawdis-mac canvas hide [--session <key>]
|
||||
clawdis-mac canvas goto --path </...> [--session <key>]
|
||||
[--x <screenX> --y <screenY>] [--width <w> --height <h>]
|
||||
clawdis-mac canvas eval --js <code> [--session <key>]
|
||||
clawdis-mac canvas snapshot [--out <path>] [--session <key>]
|
||||
|
||||
|
||||
@@ -55,6 +55,47 @@ public struct CanvasPlacement: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Canvas show result
|
||||
|
||||
public enum CanvasShowStatus: String, Codable, Sendable {
|
||||
/// Panel was shown, but no navigation occurred (no target passed and session already existed).
|
||||
case shown
|
||||
/// Target was an http(s) URL.
|
||||
case web
|
||||
/// Local canvas target resolved to an existing file.
|
||||
case ok
|
||||
/// Local canvas target did not resolve to a file (404 page).
|
||||
case notFound
|
||||
/// Local canvas root ("/") has no index, so the welcome page is shown.
|
||||
case welcome
|
||||
}
|
||||
|
||||
public struct CanvasShowResult: Codable, Sendable {
|
||||
/// Session directory on disk (e.g. `~/Library/Application Support/Clawdis/canvas/<session>/`).
|
||||
public var directory: String
|
||||
/// Target as provided by the caller (may be nil/empty).
|
||||
public var target: String?
|
||||
/// Target actually navigated to (nil when no navigation occurred; defaults to "/" for a newly created session).
|
||||
public var effectiveTarget: String?
|
||||
public var status: CanvasShowStatus
|
||||
/// URL that was loaded (nil when no navigation occurred).
|
||||
public var url: String?
|
||||
|
||||
public init(
|
||||
directory: String,
|
||||
target: String?,
|
||||
effectiveTarget: String?,
|
||||
status: CanvasShowStatus,
|
||||
url: String?)
|
||||
{
|
||||
self.directory = directory
|
||||
self.target = target
|
||||
self.effectiveTarget = effectiveTarget
|
||||
self.status = status
|
||||
self.url = url
|
||||
}
|
||||
}
|
||||
|
||||
public enum Request: Sendable {
|
||||
case notify(
|
||||
title: String,
|
||||
@@ -74,7 +115,6 @@ public enum Request: Sendable {
|
||||
case rpcStatus
|
||||
case canvasShow(session: String, path: String?, placement: CanvasPlacement?)
|
||||
case canvasHide(session: String)
|
||||
case canvasGoto(session: String, path: String, placement: CanvasPlacement?)
|
||||
case canvasEval(session: String, javaScript: String)
|
||||
case canvasSnapshot(session: String, outPath: String?)
|
||||
case nodeList
|
||||
@@ -131,7 +171,6 @@ extension Request: Codable {
|
||||
case rpcStatus
|
||||
case canvasShow
|
||||
case canvasHide
|
||||
case canvasGoto
|
||||
case canvasEval
|
||||
case canvasSnapshot
|
||||
case nodeList
|
||||
@@ -188,12 +227,6 @@ extension Request: Codable {
|
||||
try container.encode(Kind.canvasHide, forKey: .type)
|
||||
try container.encode(session, forKey: .session)
|
||||
|
||||
case let .canvasGoto(session, path, placement):
|
||||
try container.encode(Kind.canvasGoto, forKey: .type)
|
||||
try container.encode(session, forKey: .session)
|
||||
try container.encode(path, forKey: .path)
|
||||
try container.encodeIfPresent(placement, forKey: .placement)
|
||||
|
||||
case let .canvasEval(session, javaScript):
|
||||
try container.encode(Kind.canvasEval, forKey: .type)
|
||||
try container.encode(session, forKey: .session)
|
||||
@@ -278,12 +311,6 @@ extension Request: Codable {
|
||||
let session = try container.decode(String.self, forKey: .session)
|
||||
self = .canvasHide(session: session)
|
||||
|
||||
case .canvasGoto:
|
||||
let session = try container.decode(String.self, forKey: .session)
|
||||
let path = try container.decode(String.self, forKey: .path)
|
||||
let placement = try container.decodeIfPresent(CanvasPlacement.self, forKey: .placement)
|
||||
self = .canvasGoto(session: session, path: path, placement: placement)
|
||||
|
||||
case .canvasEval:
|
||||
let session = try container.decode(String.self, forKey: .session)
|
||||
let javaScript = try container.decode(String.self, forKey: .javaScript)
|
||||
|
||||
@@ -146,14 +146,6 @@ struct ControlRequestHandlerTests {
|
||||
#expect(show.ok == false)
|
||||
#expect(show.message == "Canvas disabled by user")
|
||||
|
||||
let goto = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) {
|
||||
try await Self.withDefaultOverride(canvasEnabledKey, value: false) {
|
||||
try await ControlRequestHandler.process(request: .canvasGoto(session: "s", path: "/tmp", placement: nil))
|
||||
}
|
||||
}
|
||||
#expect(goto.ok == false)
|
||||
#expect(goto.message == "Canvas disabled by user")
|
||||
|
||||
let eval = try await Self.withDefaultOverride(pauseDefaultsKey, value: false) {
|
||||
try await Self.withDefaultOverride(canvasEnabledKey, value: false) {
|
||||
try await ControlRequestHandler.process(request: .canvasEval(session: "s", javaScript: "1+1"))
|
||||
|
||||
Reference in New Issue
Block a user