diff --git a/RxCode/App/AppState.swift b/RxCode/App/AppState.swift index 75b3c60b..64e2619a 100644 --- a/RxCode/App/AppState.swift +++ b/RxCode/App/AppState.swift @@ -942,6 +942,10 @@ final class AppState { /// Set when a hook/context-menu action wants to open the global docs-capable /// search overlay in the current window. var docsSearchRequest: UUID? + /// Non-nil while the bundled User Guide should be presented, optionally at a + /// specific section. Set from the Help menu; `MainView` consumes it to show + /// `UserManualView`. + var userGuideRequest: UserGuideRequest? /// One-shot: when a docs-setup chat is kicked off, this holds the project so /// `DocsHook.onSessionStart` injects the docs skill into exactly that chat's /// system prompt, then clears it. diff --git a/RxCode/App/DocumentationCommands.swift b/RxCode/App/DocumentationCommands.swift new file mode 100644 index 00000000..7472c31f --- /dev/null +++ b/RxCode/App/DocumentationCommands.swift @@ -0,0 +1,26 @@ +import SwiftUI + +/// Replaces the default macOS **Help** menu, which otherwise looks for a +/// (nonexistent) Help Book and reports "Help isn't available for RxCode" +/// (e.g. 未找到"RxCode"的帮助). Instead it opens the bundled, offline in-app +/// **User Guide** (`UserManualView`), including a direct entry that opens the +/// guide straight to its Custom Context Menus section. +/// +/// Selecting an item sets `appState.userGuideRequest`; `MainView` consumes it +/// and presents the guide as a sheet. +struct DocumentationCommands: Commands { + let appState: AppState + + var body: some Commands { + CommandGroup(replacing: .help) { + Button("RxCode User Guide") { + appState.userGuideRequest = UserGuideRequest() + } + .keyboardShortcut("?", modifiers: .command) + + Button("Custom Context Menus Guide") { + appState.userGuideRequest = UserGuideRequest(section: "custom_context_menus") + } + } + } +} diff --git a/RxCode/App/RxCodeApp.swift b/RxCode/App/RxCodeApp.swift index 2d47a5f8..0462d7b8 100644 --- a/RxCode/App/RxCodeApp.swift +++ b/RxCode/App/RxCodeApp.swift @@ -98,6 +98,7 @@ struct RxCodeApp: App { } } AutomationCommands() + DocumentationCommands(appState: appState) } // Dedicated project window — opened on double-click diff --git a/RxCode/Resources/Localizable.xcstrings b/RxCode/Resources/Localizable.xcstrings index 9529beb9..853ee7e6 100644 --- a/RxCode/Resources/Localizable.xcstrings +++ b/RxCode/Resources/Localizable.xcstrings @@ -4498,6 +4498,9 @@ } } } + }, + "Copy device token" : { + }, "Copy install command" : { "localizations" : { @@ -4983,6 +4986,22 @@ } } }, + "Custom Context Menus Guide" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사용자 지정 컨텍스트 메뉴 가이드" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "自定义上下文菜单指南" + } + } + } + }, "Custom script" : { "localizations" : { "ko" : { @@ -13547,6 +13566,22 @@ } } }, + "RxCode User Guide" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "RxCode 사용 가이드" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "RxCode 用户指南" + } + } + } + }, "RxCode.xcodeproj" : { "localizations" : { "ko" : { @@ -17403,4 +17438,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/RxCode/Resources/user_manual_custom_context_menus.md b/RxCode/Resources/user_manual_custom_context_menus.md new file mode 100644 index 00000000..7aa1528c --- /dev/null +++ b/RxCode/Resources/user_manual_custom_context_menus.md @@ -0,0 +1,99 @@ +# Custom Context Menus + +Custom context menus let you add your own items to RxCode's right-click menus. +Each item runs an action — an HTTP request, or seeding a new / existing chat +thread — and can be scoped to specific projects, attached to one or more menu +surfaces, and gated by a Swift "show condition" that decides whether it appears. + +You configure items on the Mac (the single source of truth). The desktop renders +them natively, and the mobile app fetches the same items over the relay and +renders them identically — so there's no separate mobile setup. + +## Where to configure them + +Open the editor from either: + +- **Automation menu → Custom Context Menus** (top menu bar), or +- **Settings → General → Custom Context Menus**. + +From there you can add, edit, reorder, enable/disable, and remove items. Each row +shows the item's title, the surfaces it attaches to, and whether it has a Swift +show-condition. + +## Surfaces + +A surface is *where* the item appears. An item can attach to any combination of +the three: + +| Surface | Where it shows | +|---------|----------------| +| **Project** | The project row's context menu in the sidebar (right-click a project, or its `…` menu). | +| **Thread** | A chat thread's context menu in the sidebar. | +| **Briefing** | A branch-scoped briefing card's context menu. | + +## Scope + +- **All projects** — leave the project unset; the item appears in every project. +- **One project** — scope the item to a single project, and it appears only there. + +## Actions + +Each item performs exactly one action when tapped: + +| Action | What it does | +|--------|--------------| +| **Call API** | Sends an HTTP request. Configure the method, URL, headers, and an optional body. A non-2xx response surfaces as an error. | +| **Create Thread** | Spawns a new chat thread in the project, seeded with your message template. The spawned thread is a utility run — it does **not** trigger lifecycle hooks such as code review or auto-continue. | +| **Continue Thread** | Adds your message to an existing thread (by target thread id). | + +## Placeholders + +URLs, headers, request bodies, and message templates can use placeholders that +are substituted with the current context when the menu is built: + +| Placeholder | Value | +|-------------|-------| +| `{{projectName}}` | The project's display name | +| `{{projectPath}}` | Absolute path to the project on disk | +| `{{gitHubRepo}}` | The project's `owner/repo` (empty if none) | +| `{{branch}}` | The current branch (on briefing-card menus, the card's branch) | +| `{{sessionId}}` | The thread id (on thread menus) | + +Example URL: + +``` +https://api.example.com/projects/{{projectName}}/build?branch={{branch}} +``` + +## Show conditions + +By default every item is **always shown**. You can instead gate an item on a +Swift script that returns whether it should appear. It runs on the desktop when +the menu is built (and is warmed ahead of time for mobile). + +Write a single async function: + +```swift +func checkShowMenu(context: Context) async throws -> Bool { + // Return true to show the menu item, false to hide it. + let status = try await context.git("status", "--porcelain") + return !status.isEmpty // only show when there are uncommitted changes +} +``` + +`context` exposes the current project plus two helpers that run inside the +project directory: + +| Member | Description | +|--------|-------------| +| `projectName`, `projectPath`, `gitHubRepo`, `branch`, `sessionId` | Same values as the placeholders above. | +| `shell(_:)` | Runs a shell command in the project directory and returns its trimmed stdout. | +| `git(_:)` | Convenience wrapper that runs `git` with the given arguments in the project directory. | + +The editor refuses to save a script-gated item until it compiles, offers an +**AI generate** helper that writes the function from a plain-language +requirement, and an inline **Compile** button that surfaces diagnostics. + +> **Fail-open by design.** Every failure mode — no toolchain, compile error, +> runtime crash, a timeout, or any non-`true` output — resolves to **show** the +> item. A broken condition never silently hides a menu entry you configured. diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift index 44a10040..2aad3b72 100644 --- a/RxCode/Views/MainView.swift +++ b/RxCode/Views/MainView.swift @@ -351,6 +351,9 @@ struct MainView: View { ) .environment(appState) } + .sheet(item: Bindable(appState).userGuideRequest) { request in + UserManualView(initialSection: request.section) + } .onChange(of: appState.docsSearchRequest) { _, request in guard request != nil else { return } windowState.showGlobalSearch = true diff --git a/RxCode/Views/Settings/MobileSettingsTab.swift b/RxCode/Views/Settings/MobileSettingsTab.swift index e428c1e0..427bdf5d 100644 --- a/RxCode/Views/Settings/MobileSettingsTab.swift +++ b/RxCode/Views/Settings/MobileSettingsTab.swift @@ -12,6 +12,7 @@ struct MobileSettingsTab: View { @State private var showPairingSheet = false @State private var showAddRelaySheet = false @State private var testNotificationDeviceID: String? + @State private var copiedTokenDeviceID: String? @State private var testNotificationAlert: TestNotificationAlert? @State private var deviceBeingRenamed: PairedDevice? @State private var renameText: String = "" @@ -294,6 +295,7 @@ struct MobileSettingsTab: View { } } Spacer() + copyTokenButton(for: device) testNotificationButton(for: device) Button { renameText = device.displayName @@ -332,6 +334,25 @@ struct MobileSettingsTab: View { } } + @ViewBuilder + private func copyTokenButton(for device: PairedDevice) -> some View { + let token = MobileSyncService.pushToken(for: device) + let copied = copiedTokenDeviceID == device.id + Button { + guard let token, !token.isEmpty else { return } + copyToClipboard(token, feedback: Binding( + get: { copiedTokenDeviceID == device.id }, + set: { copiedTokenDeviceID = $0 ? device.id : nil } + )) + } label: { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .foregroundStyle(copied ? Color.green : Color.secondary) + } + .buttonStyle(.borderless) + .disabled(token?.isEmpty ?? true) + .help(copied ? "Copied" : "Copy device token") + } + @ViewBuilder private func testNotificationButton(for device: PairedDevice) -> some View { if testNotificationDeviceID == device.id { diff --git a/RxCode/Views/UserManualView.swift b/RxCode/Views/UserManualView.swift index 92d9be65..e6a3a469 100644 --- a/RxCode/Views/UserManualView.swift +++ b/RxCode/Views/UserManualView.swift @@ -2,13 +2,23 @@ import RxCodeChatKit import RxCodeCore import SwiftUI +/// One-shot request to present the bundled User Guide, optionally at a given +/// section (a `UserManualMenu` raw value). Identifiable so it drives a `.sheet`. +struct UserGuideRequest: Identifiable, Hashable { + let id = UUID() + var section: String? +} + struct UserManualView: View { @Environment(\.dismiss) private var dismiss - @State private var selectedMenu: UserManualMenu? = .overview + @State private var selectedMenu: UserManualMenu? private let documents: [UserManualMenu: BundledMarkdownDocument] - init() { + /// `initialSection` is a `UserManualMenu` raw value (e.g. + /// `"custom_context_menus"`); unknown/nil values open the overview. + init(initialSection: String? = nil) { + _selectedMenu = State(initialValue: UserManualMenu(rawValue: initialSection ?? "") ?? .overview) self.documents = Dictionary(uniqueKeysWithValues: UserManualMenu.allCases.map { menu in ( menu, @@ -125,6 +135,7 @@ private enum UserManualMenu: String, CaseIterable, Identifiable { case integrations case worktrees case hooks + case customContextMenus = "custom_context_menus" var id: String { rawValue } @@ -143,6 +154,7 @@ private enum UserManualMenu: String, CaseIterable, Identifiable { case .integrations: "MCP and ACP Clients" case .worktrees: "Git Worktrees" case .hooks: "Project Hooks" + case .customContextMenus: "Custom Context Menus" } } @@ -157,6 +169,7 @@ private enum UserManualMenu: String, CaseIterable, Identifiable { case .integrations: "puzzlepiece.extension" case .worktrees: "arrow.triangle.branch" case .hooks: "bolt.horizontal.circle" + case .customContextMenus: "filemenu.and.cursorarrow" } } } diff --git a/docs/features/custom-context-menus.md b/docs/features/custom-context-menus.md new file mode 100644 index 00000000..9d798c80 --- /dev/null +++ b/docs/features/custom-context-menus.md @@ -0,0 +1,134 @@ +--- +slug: features/custom-context-menus +title: Custom Context Menus +description: Define your own right-click menu items in RxCode — call an API or spawn a thread, scoped to projects and gated by Swift show-conditions, on desktop and mobile. +--- + +# Custom Context Menus + +Custom context menus let you add your own items to RxCode's right-click menus. +Each item runs an action — an HTTP request, or seeding a new / existing chat +thread — and can be scoped to specific projects, attached to one or more menu +surfaces, and gated by a Swift "show condition" that decides whether the item +appears. + +Items are configured on the Mac (the single source of truth) and persisted with +SwiftData. The desktop renders them natively; **mobile fetches the same items +over the relay and renders them identically**, so there is no separate mobile +setup. + +## Where to configure them + +Open the editor from either: + +- **Top menu bar → Automation → Custom Context Menus**, or +- **Settings → General → Custom Context Menus** section. + +From there you can add, edit, reorder, enable/disable, and remove items. Each +row shows the item's title, the surfaces it attaches to, and whether it has a +Swift show-condition. + +## Surfaces + +A surface is *where* the item appears. An item can attach to any combination of +the three: + +| Surface | Where it shows | +|---------|----------------| +| **Project** | The project row's context menu in the sidebar (right-click a project, or its `…` menu). | +| **Thread** | A chat thread's context menu in the sidebar. | +| **Briefing** | A branch-scoped briefing card's context menu. | + +Surfaces are stored comma-separated (e.g. `project,thread`), so an item can live +on several menus at once. + +## Scope + +- **All projects** — leave the project unset; the item appears in every project. +- **One project** — scope the item to a single project, and it only appears + there. + +## Actions + +Each item performs exactly one action when tapped: + +| Action | What it does | +|--------|--------------| +| **Call API** | Sends an HTTP request. Configure the method, URL, headers (a name→value map), and an optional request body. A non-2xx response surfaces as an error. | +| **Create Thread** | Spawns a new chat thread in the project, seeded with your message template. The spawned thread is a utility run — it does **not** trigger lifecycle hooks such as code review or auto-continue. | +| **Continue Thread** | Adds your message to an existing thread (by target thread id). | + +## Placeholders + +URLs, headers, request bodies, and message templates can use placeholders that +are substituted at menu-build time with the current context: + +| Placeholder | Value | +|-------------|-------| +| `{{projectName}}` | The project's display name | +| `{{projectPath}}` | Absolute path to the project on disk | +| `{{gitHubRepo}}` | The project's `owner/repo` (empty if none) | +| `{{branch}}` | The current branch (on briefing-card menus, the card's branch) | +| `{{sessionId}}` | The thread id (on thread menus) | + +Example URL: + +``` +https://api.example.com/projects/{{projectName}}/build?branch={{branch}} +``` + +## Show conditions + +By default every item is **always shown**. You can instead gate an item on a +Swift script that returns whether it should appear. This is evaluated on the +desktop at menu-build time (and warmed ahead of time for mobile so the phone's +first fetch is already accurate). + +Write a single async function: + +```swift +func checkShowMenu(context: Context) async throws -> Bool { + // Return true to show the menu item, false to hide it. + let status = try await context.git("status", "--porcelain") + return !status.isEmpty // only show when there are uncommitted changes +} +``` + +The `context` parameter exposes the current project and two helpers that run +inside the project directory: + +| Member | Description | +|--------|-------------| +| `projectName`, `projectPath`, `gitHubRepo`, `branch`, `sessionId` | Same values as the placeholders above. | +| `shell(_ command: String) async throws -> String` | Runs a shell command in the project directory and returns its trimmed stdout. Started with no startup files; `PATH` is injected so your tools still resolve; stderr is discarded. | +| `git(_ args: String...) async throws -> String` | Convenience wrapper that runs `git` with the given arguments in the project directory. | + +### How conditions are compiled and run + +The script is wrapped in a generated harness that defines `Context`, compiled +with `xcrun swiftc`, and the resulting binary is cached by a SHA-256 of its +source — so a given script compiles only once. Evaluation then just runs the +cached binary with the context passed through the environment. + +The editor refuses to save a script-gated item until it compiles, and offers an +**AI generate** helper that writes a `checkShowMenu` function from a +natural-language requirement, plus an inline **Compile** button that surfaces +compiler diagnostics. + +> **Fail-open by design.** Every failure mode — no toolchain, compile error, +> runtime crash, a 3-second timeout, or any non-`true` output — resolves to +> **show** the item. A broken condition never silently hides a menu entry you +> configured. + +Because synchronous menu building can't wait on a subprocess, a script result +that isn't cached yet shows the item *and* schedules a background evaluation; +the next time the menu opens it reflects the real result. + +## Desktop ↔ mobile + +You configure items once on the Mac. The desktop's `CustomMenuHook` turns each +enabled record into a serializable menu item for the matching surface, and the +mobile app fetches the same items over the relay and renders them identically. +When an item is tapped — on either side — the action is dispatched on the Mac +through a single shared entry point, so the work runs the same way regardless of +which device initiated it.