Files
openclaw-openclaw/apps/ios/Sources/Design/AgentWorkspaceFilesScreen.swift
Peter Steinberger 077e35f3b2 feat(ios): read-only workspace Files browser on the Agents surface (#100767)
* feat(ios): add read-only workspace Files browser to the Agents surface

* fix(ios): keep nav bar visible on pushed workspace file routes

* fix(ios): make workspace file navigation stack-safe

* docs(ios): note workspace file browser

* fix(ios): isolate workspace share exports

* chore(i18n): refresh iOS Files inventory

* refactor(ios): encode workspace requests before dispatch

* chore(i18n): refresh iOS inventory after rebase
2026-07-06 17:47:52 +01:00

377 lines
13 KiB
Swift

import OpenClawChatUI
import OpenClawKit
import OpenClawProtocol
import SwiftUI
/// Read-only browser for the selected agent's workspace, backed by the
/// `agents.workspace.list` / `agents.workspace.get` gateway RPCs.
struct AgentWorkspaceFilesScreen: View {
let agentId: String
let headerLeadingAction: OpenClawSidebarHeaderAction?
var body: some View {
ZStack {
OpenClawProBackground()
VStack(alignment: .leading, spacing: 0) {
if let headerLeadingAction {
OpenClawAdaptiveHeaderRow(
title: "Files",
subtitle: self.agentId,
titleFont: OpenClawType.title3SemiBold,
subtitleFont: OpenClawType.subheadMedium)
{
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
} accessory: {
EmptyView()
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
AgentWorkspaceDirectoryList(agentId: self.agentId, path: "")
}
}
.navigationTitle("Files")
.navigationBarTitleDisplayMode(.inline)
}
static func displayName(forPath path: String) -> String {
path.split(separator: "/").last.map(String.init) ?? path
}
}
struct AgentWorkspaceDirectoryList: View {
@Environment(NodeAppModel.self) var appModel
let agentId: String
let path: String
@State private var entries: [AgentsWorkspaceEntry] = []
@State private var totalEntries = 0
@State private var loading = false
@State private var loadingMore = false
@State private var errorText: String?
var body: some View {
List {
if let errorText {
Section {
Text(errorText)
.font(OpenClawType.footnote)
.foregroundStyle(OpenClawBrand.warn)
}
} else if self.entries.isEmpty, !self.loading {
Section {
Text("This folder is empty.")
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
}
}
Section {
ForEach(self.entries, id: \.path) { entry in
self.entryRow(entry)
}
if self.entries.count < self.totalEntries {
self.loadMoreRow
}
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.font(OpenClawType.body)
.overlay {
if self.loading, self.entries.isEmpty {
ProgressView()
}
}
.refreshable {
await self.reload()
}
.task(id: "\(self.agentId)|\(self.path)") {
await self.reload()
}
}
private func entryRow(_ entry: AgentsWorkspaceEntry) -> some View {
let isDirectory = self.isDirectory(entry)
return NavigationLink {
if isDirectory {
ZStack {
OpenClawProBackground()
AgentWorkspaceDirectoryList(agentId: self.agentId, path: entry.path)
}
.navigationTitle(AgentWorkspaceFilesScreen.displayName(forPath: entry.path))
.navigationBarTitleDisplayMode(.inline)
.toolbar(.visible, for: .navigationBar)
} else {
AgentWorkspaceFilePreview(agentId: self.agentId, path: entry.path)
.toolbar(.visible, for: .navigationBar)
}
} label: {
HStack(spacing: 12) {
Image(systemName: isDirectory ? "folder" : "doc.text")
.font(OpenClawType.subhead)
.foregroundStyle(isDirectory ? OpenClawBrand.accent : Color.secondary)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(entry.name)
.font(OpenClawType.subhead)
.foregroundStyle(.primary)
.lineLimit(1)
if let detail = self.entryDetail(entry) {
Text(detail)
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
}
}
private var loadMoreRow: some View {
Button {
Task { await self.loadMore() }
} label: {
HStack {
Text("Load More")
.font(OpenClawType.subheadMedium)
Spacer()
if self.loadingMore {
ProgressView()
} else {
Text("\(self.entries.count) of \(self.totalEntries)")
.font(OpenClawType.caption)
.foregroundStyle(.secondary)
}
}
}
.disabled(self.loadingMore)
}
private func isDirectory(_ entry: AgentsWorkspaceEntry) -> Bool {
(entry.kind.value as? String) == "directory"
}
private func entryDetail(_ entry: AgentsWorkspaceEntry) -> String? {
var parts: [String] = []
if let size = entry.size {
parts.append(ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file))
}
if let updatedAtMs = entry.updatedatms {
let date = Date(timeIntervalSince1970: Double(updatedAtMs) / 1000)
parts.append(date.formatted(.relative(presentation: .named, unitsStyle: .abbreviated)))
}
return parts.isEmpty ? nil : parts.joined(separator: "")
}
@MainActor
private func reload() async {
self.loading = true
self.errorText = nil
defer { self.loading = false }
if let result = await self.fetchPage(offset: 0) {
self.entries = result.entries
self.totalEntries = result.totalentries
}
}
@MainActor
private func loadMore() async {
guard !self.loadingMore else { return }
self.loadingMore = true
defer { self.loadingMore = false }
if let result = await self.fetchPage(offset: self.entries.count) {
let known = Set(self.entries.map(\.path))
self.entries.append(contentsOf: result.entries.filter { !known.contains($0.path) })
self.totalEntries = result.totalentries
}
}
@MainActor
private func fetchPage(offset: Int) async -> AgentsWorkspaceListResult? {
do {
let params = AgentsWorkspaceListParams(
agentid: self.agentId,
path: self.path.isEmpty ? nil : self.path,
offset: offset == 0 ? nil : offset,
limit: nil)
let paramsJSON = try Self.encodeParams(params)
let data = try await self.appModel.operatorSession.request(
method: "agents.workspace.list",
paramsJSON: paramsJSON,
timeoutSeconds: 12)
return try JSONDecoder().decode(AgentsWorkspaceListResult.self, from: data)
} catch {
self.errorText = "Could not load this folder."
return nil
}
}
static func encodeParams(_ params: some Encodable) throws -> String {
let data = try JSONEncoder().encode(params)
return String(data: data, encoding: .utf8) ?? "{}"
}
}
struct AgentWorkspaceFilePreview: View {
@Environment(NodeAppModel.self) var appModel
let agentId: String
let path: String
private struct ShareItem: Identifiable {
let id = UUID()
let fileURL: URL
}
@State private var file: AgentsWorkspaceFile?
@State private var loading = false
@State private var errorText: String?
@State private var shareItem: ShareItem?
@State private var showsShareError = false
var body: some View {
ZStack {
OpenClawProBackground()
self.content
}
.navigationTitle(AgentWorkspaceFilesScreen.displayName(forPath: self.path))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button {
self.share()
} label: {
Image(systemName: "square.and.arrow.up")
}
.accessibilityLabel("Share file")
.disabled(self.file == nil)
}
}
.sheet(item: self.$shareItem) { item in
ChatTranscriptShareSheet(fileURL: item.fileURL)
}
.alert("Could not share this file.", isPresented: self.$showsShareError) {
Button {
self.showsShareError = false
} label: {
Text("OK")
.font(OpenClawType.subheadMedium)
}
}
.task(id: "\(self.agentId)|\(self.path)") {
await self.load()
}
}
@ViewBuilder
private var content: some View {
if let file {
self.preview(for: file)
} else if self.loading {
ProgressView()
} else if let errorText {
VStack(spacing: 8) {
Image(systemName: "doc.questionmark")
.font(OpenClawType.title3SemiBold)
.foregroundStyle(.secondary)
Text(errorText)
.font(OpenClawType.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(OpenClawProMetric.pagePadding)
}
}
@ViewBuilder
private func preview(for file: AgentsWorkspaceFile) -> some View {
if self.isImage(file), let image = self.decodedImage(file) {
ScrollView([.horizontal, .vertical]) {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
.padding(OpenClawProMetric.pagePadding)
}
} else {
ScrollView([.horizontal, .vertical]) {
Text(ChatCodeHighlightCache.highlighted(
code: file.content,
languageId: self.languageId))
.font(OpenClawType.monoSmall)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(OpenClawProMetric.pagePadding)
}
}
}
private var fileExtension: String {
(self.path as NSString).pathExtension.lowercased()
}
/// The highlighter's language ids line up with common file extensions;
/// unknown ids fall back to plain text rendering.
private var languageId: String? {
self.fileExtension.isEmpty ? nil : self.fileExtension
}
private func isImage(_ file: AgentsWorkspaceFile) -> Bool {
(file.encoding.value as? String) == "base64" && file.mimetype.hasPrefix("image/")
}
private func decodedImage(_ file: AgentsWorkspaceFile) -> UIImage? {
guard let data = Data(base64Encoded: file.content) else { return nil }
return UIImage(data: data)
}
@MainActor
private func load() async {
self.loading = true
self.file = nil
self.errorText = nil
defer { self.loading = false }
do {
let params = AgentsWorkspaceGetParams(agentid: self.agentId, path: self.path)
let paramsJSON = try AgentWorkspaceDirectoryList.encodeParams(params)
let data = try await self.appModel.operatorSession.request(
method: "agents.workspace.get",
paramsJSON: paramsJSON,
timeoutSeconds: 20)
self.file = try JSONDecoder().decode(AgentsWorkspaceGetResult.self, from: data).file
} catch {
self.errorText = "This file cannot be previewed. It may be binary or too large."
}
}
/// Mirrors the chat transcript export flow: write a temp copy, then hand
/// it to the system share sheet.
private func share() {
guard let file else { return }
let safeName = (file.name as NSString).lastPathComponent
guard !safeName.isEmpty, safeName != ".", safeName != ".." else {
self.showsShareError = true
return
}
// Each share gets stable bytes at a unique URL; a later same-basename
// export must not replace the item already handed to an extension.
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("OpenClawWorkspaceFiles", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let fileURL = directory.appendingPathComponent(safeName, isDirectory: false)
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
if self.isImage(file) {
guard let data = Data(base64Encoded: file.content) else {
self.showsShareError = true
return
}
try data.write(to: fileURL, options: .atomic)
} else {
try file.content.write(to: fileURL, atomically: true, encoding: .utf8)
}
self.shareItem = ShareItem(fileURL: fileURL)
} catch {
self.showsShareError = true
}
}
}