| author | |
| committer | |
| log | 2d156a83b96ebf2e4a9c6017251baad357ae6ab1 |
| tree | 1b5f613c9facbd79feaa3c699fd7d8f808f6d44d |
| parent | b5b6abb11e61ad2c9946b8d1346653c6c43d2261 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>20 files changed, 500 insertions(+), 16 deletions(-)
apps/mobile/modules/t3-native-controls/android/src/main/java/expo/modules/t3nativecontrols/T3KeyboardCommandsModule.kt created+46| ... | @@ -0,0 +1,46 @@ | ||
| 1 | package expo.modules.t3nativecontrols | ||
| 2 | |||
| 3 | import android.content.Context | ||
| 4 | import android.view.KeyEvent | ||
| 5 | import expo.modules.kotlin.AppContext | ||
| 6 | import expo.modules.kotlin.modules.Module | ||
| 7 | import expo.modules.kotlin.modules.ModuleDefinition | ||
| 8 | import expo.modules.kotlin.viewevent.EventDispatcher | ||
| 9 | import expo.modules.kotlin.views.ExpoView | ||
| 10 | |||
| 11 | class T3KeyboardCommandsModule : Module() { | ||
| 12 | override fun definition() = ModuleDefinition { | ||
| 13 | Name("T3KeyboardCommands") | ||
| 14 | |||
| 15 | View(T3KeyboardCommandsView::class) { | ||
| 16 | Prop("enabledCommands") { view: T3KeyboardCommandsView, commands: List<String> -> | ||
| 17 | view.enabledCommands = commands.toSet() | ||
| 18 | } | ||
| 19 | Events("onCommand") | ||
| 20 | } | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | class T3KeyboardCommandsView( | ||
| 25 | context: Context, | ||
| 26 | appContext: AppContext | ||
| 27 | ) : ExpoView(context, appContext) { | ||
| 28 | private val onCommand by EventDispatcher() | ||
| 29 | var enabledCommands = emptySet<String>() | ||
| 30 | |||
| 31 | override fun dispatchKeyEvent(event: KeyEvent): Boolean { | ||
| 32 | val copiesThreadReference = | ||
| 33 | event.action == KeyEvent.ACTION_DOWN && | ||
| 34 | event.repeatCount == 0 && | ||
| 35 | event.keyCode == KeyEvent.KEYCODE_C && | ||
| 36 | event.isCtrlPressed && | ||
| 37 | event.isShiftPressed && | ||
| 38 | !event.isAltPressed && | ||
| 39 | enabledCommands.contains("copyThreadReference") | ||
| 40 | if (copiesThreadReference) { | ||
| 41 | onCommand(mapOf("command" to "copyThreadReference")) | ||
| 42 | return true | ||
| 43 | } | ||
| 44 | return super.dispatchKeyEvent(event) | ||
| 45 | } | ||
| 46 | } | ||
apps/mobile/modules/t3-native-controls/expo-module.config.json+4-1| ... | @@ -4,6 +4,9 @@ | ... | @@ -4,6 +4,9 @@ |
| 4 | "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"] | 4 | "modules": ["T3NativeControlsModule", "T3KeyboardCommandsModule"] |
| 5 | }, | 5 | }, |
| 6 | "android": { | 6 | "android": { |
| 7 | "modules": ["expo.modules.t3nativecontrols.T3NativeControlsModule"] | 7 | "modules": [ |
| 8 | "expo.modules.t3nativecontrols.T3NativeControlsModule", | ||
| 9 | "expo.modules.t3nativecontrols.T3KeyboardCommandsModule" | ||
| 10 | ] | ||
| 8 | } | 11 | } |
| 9 | } | 12 | } |
apps/mobile/modules/t3-native-controls/ios/T3KeyboardCommandsModule.swift+8| ... | @@ -29,6 +29,13 @@ public final class T3KeyboardCommandsView: ExpoView { | ... | @@ -29,6 +29,13 @@ public final class T3KeyboardCommandsView: ExpoView { |
| 29 | enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"), | 29 | enabledCommand("files", input: "f", modifiers: [.command, .shift], action: #selector(openFiles), title: "Open Files"), |
| 30 | enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"), | 30 | enabledCommand("terminal", input: "t", modifiers: [.command, .shift], action: #selector(openTerminal), title: "Open Terminal"), |
| 31 | enabledCommand("review", input: "r", modifiers: [.command, .shift], action: #selector(openReview), title: "Open Review"), | 31 | enabledCommand("review", input: "r", modifiers: [.command, .shift], action: #selector(openReview), title: "Open Review"), |
| 32 | enabledCommand( | ||
| 33 | "copyThreadReference", | ||
| 34 | input: "c", | ||
| 35 | modifiers: [.command, .shift], | ||
| 36 | action: #selector(copyThreadReference), | ||
| 37 | title: "Copy PR Link or Thread ID" | ||
| 38 | ), | ||
| 32 | enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"), | 39 | enabledCommand("toggleSidebar", input: "\\", modifiers: .command, action: #selector(handleToggleSidebar), title: "Toggle Sidebar"), |
| 33 | ].compactMap { $0 } | 40 | ].compactMap { $0 } |
| 34 | } | 41 | } |
| ... | @@ -106,6 +113,7 @@ public final class T3KeyboardCommandsView: ExpoView { | ... | @@ -106,6 +113,7 @@ public final class T3KeyboardCommandsView: ExpoView { |
| 106 | @objc private func openFiles() { emit("files") } | 113 | @objc private func openFiles() { emit("files") } |
| 107 | @objc private func openTerminal() { emit("terminal") } | 114 | @objc private func openTerminal() { emit("terminal") } |
| 108 | @objc private func openReview() { emit("review") } | 115 | @objc private func openReview() { emit("review") } |
| 116 | @objc private func copyThreadReference() { emit("copyThreadReference") } | ||
| 109 | @objc private func handleToggleSidebar() { emit("toggleSidebar") } | 117 | @objc private func handleToggleSidebar() { emit("toggleSidebar") } |
| 110 | 118 | ||
| 111 | private func emit(_ command: String) { | 119 | private func emit(_ command: String) { |
apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx+124-6| ... | @@ -1,7 +1,22 @@ | ... | @@ -1,7 +1,22 @@ |
| 1 | import { StackActions, useNavigation } from "@react-navigation/native"; | 1 | import { StackActions, useNavigation } from "@react-navigation/native"; |
| 2 | import { useCallback, useMemo, useSyncExternalStore, type PropsWithChildren } from "react"; | 2 | import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; |
| 3 | import { | ||
| 4 | useCallback, | ||
| 5 | useEffect, | ||
| 6 | useMemo, | ||
| 7 | useRef, | ||
| 8 | useState, | ||
| 9 | useSyncExternalStore, | ||
| 10 | type PropsWithChildren, | ||
| 11 | } from "react"; | ||
| 3 | 12 | ||
| 13 | import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | ||
| 4 | import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; | 14 | import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; |
| 15 | import { useProject, useThreadShell } from "../../state/entities"; | ||
| 16 | import { useEnvironmentQuery } from "../../state/query"; | ||
| 17 | import type { GitActionProgress } from "../../state/use-vcs-action-state"; | ||
| 18 | import { vcsEnvironment } from "../../state/vcs"; | ||
| 19 | import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay"; | ||
| 5 | import { | 20 | import { |
| 6 | dispatchHardwareKeyboardCommand, | 21 | dispatchHardwareKeyboardCommand, |
| 7 | getHardwareKeyboardCommandRegistrationVersion, | 22 | getHardwareKeyboardCommandRegistrationVersion, |
| ... | @@ -11,11 +26,86 @@ import { | ... | @@ -11,11 +26,86 @@ import { |
| 11 | type HardwareKeyboardCommand, | 26 | type HardwareKeyboardCommand, |
| 12 | } from "./hardwareKeyboardCommands"; | 27 | } from "./hardwareKeyboardCommands"; |
| 13 | 28 | ||
| 29 | const EMPTY_COPY_FEEDBACK: GitActionProgress = { | ||
| 30 | phase: "idle", | ||
| 31 | label: null, | ||
| 32 | description: null, | ||
| 33 | }; | ||
| 34 | const COPY_FEEDBACK_DISMISS_MS = 3_000; | ||
| 35 | |||
| 14 | export function HardwareKeyboardCommandProvider({ | 36 | export function HardwareKeyboardCommandProvider({ |
| 15 | children, | 37 | children, |
| 16 | pathname, | 38 | pathname, |
| 17 | }: PropsWithChildren<{ readonly pathname: string }>) { | 39 | }: PropsWithChildren<{ readonly pathname: string }>) { |
| 18 | const navigation = useNavigation(); | 40 | const navigation = useNavigation(); |
| 41 | const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]); | ||
| 42 | const activeThread = useThreadShell(activeThreadRef); | ||
| 43 | const activeProjectRef = useMemo( | ||
| 44 | () => | ||
| 45 | activeThread === null | ||
| 46 | ? null | ||
| 47 | : { | ||
| 48 | environmentId: activeThread.environmentId, | ||
| 49 | projectId: activeThread.projectId, | ||
| 50 | }, | ||
| 51 | [activeThread], | ||
| 52 | ); | ||
| 53 | const activeProject = useProject(activeProjectRef); | ||
| 54 | const activeThreadCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot ?? null; | ||
| 55 | const gitStatus = useEnvironmentQuery( | ||
| 56 | activeThread !== null && | ||
| 57 | activeThread.linkedPullRequest == null && | ||
| 58 | activeThread.branch !== null && | ||
| 59 | activeThreadCwd !== null | ||
| 60 | ? vcsEnvironment.status({ | ||
| 61 | environmentId: activeThread.environmentId, | ||
| 62 | input: { cwd: activeThreadCwd }, | ||
| 63 | }) | ||
| 64 | : null, | ||
| 65 | ).data; | ||
| 66 | const detectedPullRequestUrl = | ||
| 67 | activeThread?.branch != null && gitStatus?.refName === activeThread.branch | ||
| 68 | ? (gitStatus.pr?.url ?? null) | ||
| 69 | : null; | ||
| 70 | const copyTarget = useMemo( | ||
| 71 | () => | ||
| 72 | activeThreadRef === null | ||
| 73 | ? null | ||
| 74 | : resolveThreadReferenceCopyTarget({ | ||
| 75 | threadId: activeThread?.id ?? activeThreadRef.threadId, | ||
| 76 | linkedPullRequestUrl: activeThread?.linkedPullRequest?.url ?? null, | ||
| 77 | detectedPullRequestUrl, | ||
| 78 | }), | ||
| 79 | [activeThread, activeThreadRef, detectedPullRequestUrl], | ||
| 80 | ); | ||
| 81 | const [copyFeedback, setCopyFeedback] = useState<GitActionProgress>(EMPTY_COPY_FEEDBACK); | ||
| 82 | const copyRequestIdRef = useRef(0); | ||
| 83 | const copyFeedbackTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| 84 | const dismissCopyFeedback = useCallback(() => { | ||
| 85 | if (copyFeedbackTimerRef.current !== null) { | ||
| 86 | clearTimeout(copyFeedbackTimerRef.current); | ||
| 87 | copyFeedbackTimerRef.current = null; | ||
| 88 | } | ||
| 89 | setCopyFeedback(EMPTY_COPY_FEEDBACK); | ||
| 90 | }, []); | ||
| 91 | const showCopyFeedback = useCallback((feedback: GitActionProgress) => { | ||
| 92 | if (copyFeedbackTimerRef.current !== null) { | ||
| 93 | clearTimeout(copyFeedbackTimerRef.current); | ||
| 94 | } | ||
| 95 | setCopyFeedback(feedback); | ||
| 96 | copyFeedbackTimerRef.current = setTimeout(() => { | ||
| 97 | copyFeedbackTimerRef.current = null; | ||
| 98 | setCopyFeedback(EMPTY_COPY_FEEDBACK); | ||
| 99 | }, COPY_FEEDBACK_DISMISS_MS); | ||
| 100 | }, []); | ||
| 101 | useEffect( | ||
| 102 | () => () => { | ||
| 103 | if (copyFeedbackTimerRef.current !== null) { | ||
| 104 | clearTimeout(copyFeedbackTimerRef.current); | ||
| 105 | } | ||
| 106 | }, | ||
| 107 | [], | ||
| 108 | ); | ||
| 19 | const registrationVersion = useSyncExternalStore( | 109 | const registrationVersion = useSyncExternalStore( |
| 20 | subscribeToHardwareKeyboardCommandRegistrations, | 110 | subscribeToHardwareKeyboardCommandRegistrations, |
| 21 | getHardwareKeyboardCommandRegistrationVersion, | 111 | getHardwareKeyboardCommandRegistrationVersion, |
| ... | @@ -25,10 +115,11 @@ export function HardwareKeyboardCommandProvider({ | ... | @@ -25,10 +115,11 @@ export function HardwareKeyboardCommandProvider({ |
| 25 | const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands()); | 115 | const commands = new Set<HardwareKeyboardCommand>(getRegisteredHardwareKeyboardCommands()); |
| 26 | commands.add("newTask"); | 116 | commands.add("newTask"); |
| 27 | if (pathname !== "/" || navigation.canGoBack()) commands.add("back"); | 117 | if (pathname !== "/" || navigation.canGoBack()) commands.add("back"); |
| 28 | if (parseActiveThreadPath(pathname)) { | 118 | if (activeThreadRef !== null) { |
| 29 | commands.add("files"); | 119 | commands.add("files"); |
| 30 | commands.add("terminal"); | 120 | commands.add("terminal"); |
| 31 | commands.add("review"); | 121 | commands.add("review"); |
| 122 | if (pathname.split("/")[4] !== "terminal") commands.add("copyThreadReference"); | ||
| 32 | } | 123 | } |
| 33 | return [...commands]; | 124 | return [...commands]; |
| 34 | }, [pathname, registrationVersion, navigation]); | 125 | }, [pathname, registrationVersion, navigation]); |
| ... | @@ -37,6 +128,30 @@ export function HardwareKeyboardCommandProvider({ | ... | @@ -37,6 +128,30 @@ export function HardwareKeyboardCommandProvider({ |
| 37 | (command: HardwareKeyboardCommand) => { | 128 | (command: HardwareKeyboardCommand) => { |
| 38 | if (dispatchHardwareKeyboardCommand(command)) return; | 129 | if (dispatchHardwareKeyboardCommand(command)) return; |
| 39 | 130 | ||
| 131 | if (command === "copyThreadReference") { | ||
| 132 | if (copyTarget === null) return; | ||
| 133 | const requestId = ++copyRequestIdRef.current; | ||
| 134 | void tryCopyTextWithHaptic(copyTarget.value, { | ||
| 135 | target: copyTarget.clipboardTarget, | ||
| 136 | }).then((didCopy) => { | ||
| 137 | if (requestId !== copyRequestIdRef.current) return; | ||
| 138 | showCopyFeedback( | ||
| 139 | didCopy | ||
| 140 | ? { | ||
| 141 | phase: "success", | ||
| 142 | label: copyTarget.successTitle, | ||
| 143 | description: copyTarget.value, | ||
| 144 | } | ||
| 145 | : { | ||
| 146 | phase: "error", | ||
| 147 | label: copyTarget.failureTitle, | ||
| 148 | description: "Try again.", | ||
| 149 | }, | ||
| 150 | ); | ||
| 151 | }); | ||
| 152 | return; | ||
| 153 | } | ||
| 154 | |||
| 40 | if (command === "newTask") { | 155 | if (command === "newTask") { |
| 41 | navigation.navigate("NewTaskSheet", { screen: "NewTask" }); | 156 | navigation.navigate("NewTaskSheet", { screen: "NewTask" }); |
| 42 | return; | 157 | return; |
| ... | @@ -62,12 +177,15 @@ export function HardwareKeyboardCommandProvider({ | ... | @@ -62,12 +177,15 @@ export function HardwareKeyboardCommandProvider({ |
| 62 | navigation.navigate("ThreadReview", thread); | 177 | navigation.navigate("ThreadReview", thread); |
| 63 | } | 178 | } |
| 64 | }, | 179 | }, |
| 65 | [pathname, navigation], | 180 | [copyTarget, navigation, pathname, showCopyFeedback], |
| 66 | ); | 181 | ); |
| 67 | 182 | ||
| 68 | return ( | 183 | return ( |
| 69 | <T3KeyboardCommands enabledCommands={enabledCommands} onCommand={onCommand}> | 184 | <> |
| 70 | {children} | 185 | <T3KeyboardCommands enabledCommands={enabledCommands} onCommand={onCommand}> |
| 71 | </T3KeyboardCommands> | 186 | {children} |
| 187 | </T3KeyboardCommands> | ||
| 188 | <GitActionProgressOverlay progress={copyFeedback} onDismiss={dismissCopyFeedback} /> | ||
| 189 | </> | ||
| 72 | ); | 190 | ); |
| 73 | } | 191 | } |
apps/mobile/src/features/keyboard/hardwareKeyboardCommands.ts+1| ... | @@ -8,6 +8,7 @@ export type HardwareKeyboardCommand = | ... | @@ -8,6 +8,7 @@ export type HardwareKeyboardCommand = |
| 8 | | "files" | 8 | | "files" |
| 9 | | "terminal" | 9 | | "terminal" |
| 10 | | "review" | 10 | | "review" |
| 11 | | "copyThreadReference" | ||
| 11 | | "toggleSidebar"; | 12 | | "toggleSidebar"; |
| 12 | 13 | ||
| 13 | type CommandHandler = () => boolean | void; | 14 | type CommandHandler = () => boolean | void; |
apps/mobile/src/lib/copyTextWithHaptic.test.ts+14| ... | @@ -22,6 +22,7 @@ import { | ... | @@ -22,6 +22,7 @@ import { |
| 22 | CopyTextClipboardWriteError, | 22 | CopyTextClipboardWriteError, |
| 23 | CopyTextHapticFeedbackError, | 23 | CopyTextHapticFeedbackError, |
| 24 | copyTextWithHaptic, | 24 | copyTextWithHaptic, |
| 25 | tryCopyTextWithHaptic, | ||
| 25 | } from "./copyTextWithHaptic"; | 26 | } from "./copyTextWithHaptic"; |
| 26 | 27 | ||
| 27 | describe("copyTextWithHaptic", () => { | 28 | describe("copyTextWithHaptic", () => { |
| ... | @@ -54,6 +55,19 @@ describe("copyTextWithHaptic", () => { | ... | @@ -54,6 +55,19 @@ describe("copyTextWithHaptic", () => { |
| 54 | expect(mocks.impactAsync).not.toHaveBeenCalled(); | 55 | expect(mocks.impactAsync).not.toHaveBeenCalled(); |
| 55 | }); | 56 | }); |
| 56 | 57 | ||
| 58 | it("reports whether the clipboard write succeeded", async () => { | ||
| 59 | mocks.setStringAsync.mockResolvedValueOnce(undefined); | ||
| 60 | |||
| 61 | await expect(tryCopyTextWithHaptic("thread-123")).resolves.toBe(true); | ||
| 62 | }); | ||
| 63 | |||
| 64 | it("returns false when the clipboard write fails", async () => { | ||
| 65 | vi.spyOn(console, "error").mockImplementation(() => undefined); | ||
| 66 | mocks.setStringAsync.mockRejectedValueOnce(new Error("native clipboard failure")); | ||
| 67 | |||
| 68 | await expect(tryCopyTextWithHaptic("thread-123")).resolves.toBe(false); | ||
| 69 | }); | ||
| 70 | |||
| 57 | it("reports structured failures without including clipboard contents", async () => { | 71 | it("reports structured failures without including clipboard contents", async () => { |
| 58 | const clipboardCause = new Error("native clipboard failure"); | 72 | const clipboardCause = new Error("native clipboard failure"); |
| 59 | const hapticCause = new Error("native haptic failure"); | 73 | const hapticCause = new Error("native haptic failure"); |
apps/mobile/src/lib/copyTextWithHaptic.ts+17-7| ... | @@ -27,19 +27,22 @@ export class CopyTextHapticFeedbackError extends Schema.TaggedErrorClass<CopyTex | ... | @@ -27,19 +27,22 @@ export class CopyTextHapticFeedbackError extends Schema.TaggedErrorClass<CopyTex |
| 27 | } | 27 | } |
| 28 | } | 28 | } |
| 29 | 29 | ||
| 30 | export function copyTextWithHaptic( | 30 | interface CopyTextWithHapticOptions { |
| 31 | readonly target?: string; | ||
| 32 | readonly feedback?: "light-impact" | "selection"; | ||
| 33 | } | ||
| 34 | |||
| 35 | export async function tryCopyTextWithHaptic( | ||
| 31 | value: string, | 36 | value: string, |
| 32 | options: { | 37 | options: CopyTextWithHapticOptions = {}, |
| 33 | readonly target?: string; | 38 | ): Promise<boolean> { |
| 34 | readonly feedback?: "light-impact" | "selection"; | ||
| 35 | } = {}, | ||
| 36 | ): void { | ||
| 37 | const target = options.target ?? "text"; | 39 | const target = options.target ?? "text"; |
| 38 | const feedback = options.feedback ?? "light-impact"; | 40 | const feedback = options.feedback ?? "light-impact"; |
| 39 | 41 | ||
| 40 | void (async () => { | 42 | const clipboardWrite = (async () => { |
| 41 | try { | 43 | try { |
| 42 | await Clipboard.setStringAsync(value); | 44 | await Clipboard.setStringAsync(value); |
| 45 | return true; | ||
| 43 | } catch (cause) { | 46 | } catch (cause) { |
| 44 | console.error( | 47 | console.error( |
| 45 | new CopyTextClipboardWriteError({ | 48 | new CopyTextClipboardWriteError({ |
| ... | @@ -47,6 +50,7 @@ export function copyTextWithHaptic( | ... | @@ -47,6 +50,7 @@ export function copyTextWithHaptic( |
| 47 | cause, | 50 | cause, |
| 48 | }), | 51 | }), |
| 49 | ); | 52 | ); |
| 53 | return false; | ||
| 50 | } | 54 | } |
| 51 | })(); | 55 | })(); |
| 52 | 56 | ||
| ... | @@ -67,4 +71,10 @@ export function copyTextWithHaptic( | ... | @@ -67,4 +71,10 @@ export function copyTextWithHaptic( |
| 67 | ); | 71 | ); |
| 68 | } | 72 | } |
| 69 | })(); | 73 | })(); |
| 74 | |||
| 75 | return await clipboardWrite; | ||
| 76 | } | ||
| 77 | |||
| 78 | export function copyTextWithHaptic(value: string, options: CopyTextWithHapticOptions = {}): void { | ||
| 79 | void tryCopyTextWithHaptic(value, options); | ||
| 70 | } | 80 | } |
apps/mobile/src/native/T3KeyboardCommands.android.tsx created+31| ... | @@ -0,0 +1,31 @@ | ||
| 1 | import { requireNativeView } from "expo"; | ||
| 2 | import type { PropsWithChildren } from "react"; | ||
| 3 | import type { NativeSyntheticEvent, ViewProps } from "react-native"; | ||
| 4 | |||
| 5 | import type { HardwareKeyboardCommand } from "../features/keyboard/hardwareKeyboardCommands"; | ||
| 6 | |||
| 7 | interface NativeKeyboardCommandsProps extends ViewProps, PropsWithChildren { | ||
| 8 | readonly enabledCommands: ReadonlyArray<HardwareKeyboardCommand>; | ||
| 9 | readonly onCommand: ( | ||
| 10 | event: NativeSyntheticEvent<{ readonly command: HardwareKeyboardCommand }>, | ||
| 11 | ) => void; | ||
| 12 | } | ||
| 13 | |||
| 14 | const NativeKeyboardCommands = requireNativeView<NativeKeyboardCommandsProps>("T3KeyboardCommands"); | ||
| 15 | |||
| 16 | export function T3KeyboardCommands( | ||
| 17 | props: PropsWithChildren<{ | ||
| 18 | readonly enabledCommands: ReadonlyArray<HardwareKeyboardCommand>; | ||
| 19 | readonly onCommand: (command: HardwareKeyboardCommand) => void; | ||
| 20 | }>, | ||
| 21 | ) { | ||
| 22 | return ( | ||
| 23 | <NativeKeyboardCommands | ||
| 24 | onCommand={(event) => props.onCommand(event.nativeEvent.command)} | ||
| 25 | enabledCommands={props.enabledCommands} | ||
| 26 | style={{ flex: 1 }} | ||
| 27 | > | ||
| 28 | {props.children} | ||
| 29 | </NativeKeyboardCommands> | ||
| 30 | ); | ||
| 31 | } | ||
apps/mobile/src/state/use-thread-selection.ts+1| ... | @@ -54,6 +54,7 @@ function threadDetailToShell( | ... | @@ -54,6 +54,7 @@ function threadDetailToShell( |
| 54 | interactionMode: thread.interactionMode, | 54 | interactionMode: thread.interactionMode, |
| 55 | branch: thread.branch, | 55 | branch: thread.branch, |
| 56 | worktreePath: thread.worktreePath, | 56 | worktreePath: thread.worktreePath, |
| 57 | linkedPullRequest: thread.linkedPullRequest ?? null, | ||
| 57 | latestTurn: thread.latestTurn, | 58 | latestTurn: thread.latestTurn, |
| 58 | createdAt: thread.createdAt, | 59 | createdAt: thread.createdAt, |
| 59 | updatedAt: thread.updatedAt, | 60 | updatedAt: thread.updatedAt, |
apps/server/src/keybindings.test.ts+1| ... | @@ -195,6 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { | ... | @@ -195,6 +195,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { |
| 195 | 195 | ||
| 196 | assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); | 196 | assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); |
| 197 | assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); | 197 | assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); |
| 198 | assert.equal(defaultsByCommand.get("thread.copyReference"), "mod+shift+c"); | ||
| 198 | assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s"); | 199 | assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s"); |
| 199 | assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p"); | 200 | assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p"); |
| 200 | assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); | 201 | assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); |
apps/web/src/components/ChatView.tsx+44| ... | @@ -50,6 +50,7 @@ import { | ... | @@ -50,6 +50,7 @@ import { |
| 50 | } from "@t3tools/shared/model"; | 50 | } from "@t3tools/shared/model"; |
| 51 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; | 51 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 52 | import { truncate } from "@t3tools/shared/String"; | 52 | import { truncate } from "@t3tools/shared/String"; |
| 53 | import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; | ||
| 53 | import { | 54 | import { |
| 54 | getTerminalLabel, | 55 | getTerminalLabel, |
| 55 | nextTerminalId, | 56 | nextTerminalId, |
| ... | @@ -4593,6 +4594,41 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -4593,6 +4594,41 @@ function ChatViewContent(props: ChatViewProps) { |
| 4593 | linkedPullRequest: linkedThreadPullRequest, | 4594 | linkedPullRequest: linkedThreadPullRequest, |
| 4594 | linkedPullRequestStatus, | 4595 | linkedPullRequestStatus, |
| 4595 | }); | 4596 | }); |
| 4597 | const activeThreadReferenceCopyTarget = useMemo( | ||
| 4598 | () => | ||
| 4599 | activeThreadId === null || !isServerThread | ||
| 4600 | ? null | ||
| 4601 | : resolveThreadReferenceCopyTarget({ | ||
| 4602 | threadId: activeThreadId, | ||
| 4603 | linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null, | ||
| 4604 | detectedPullRequestUrl: activeThreadPr?.url ?? null, | ||
| 4605 | }), | ||
| 4606 | [activeThreadId, activeThreadPr?.url, isServerThread, linkedThreadPullRequest?.url], | ||
| 4607 | ); | ||
| 4608 | const copyActiveThreadReference = useCallback(() => { | ||
| 4609 | const target = activeThreadReferenceCopyTarget; | ||
| 4610 | if (target === null) return; | ||
| 4611 | void writeTextToClipboard(target.value, target.clipboardTarget).then( | ||
| 4612 | (didCopy) => { | ||
| 4613 | if (!didCopy) return; | ||
| 4614 | toastManager.add({ | ||
| 4615 | type: "success", | ||
| 4616 | title: target.successTitle, | ||
| 4617 | description: target.value, | ||
| 4618 | }); | ||
| 4619 | }, | ||
| 4620 | (error) => { | ||
| 4621 | console.error(error); | ||
| 4622 | toastManager.add( | ||
| 4623 | stackedThreadToast({ | ||
| 4624 | type: "error", | ||
| 4625 | title: target.failureTitle, | ||
| 4626 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 4627 | }), | ||
| 4628 | ); | ||
| 4629 | }, | ||
| 4630 | ); | ||
| 4631 | }, [activeThreadReferenceCopyTarget]); | ||
| 4596 | // The right panel offers the thread's own change request, so it can only offer it once the | 4632 | // The right panel offers the thread's own change request, so it can only offer it once the |
| 4597 | // branch has one; until then the picker says so rather than opening an empty panel. | 4633 | // branch has one; until then the picker says so rather than opening an empty panel. |
| 4598 | const addPullRequestSurface = useCallback(() => { | 4634 | const addPullRequestSurface = useCallback(() => { |
| ... | @@ -5257,6 +5293,13 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5257,6 +5293,13 @@ function ChatViewContent(props: ChatViewProps) { |
| 5257 | }); | 5293 | }); |
| 5258 | if (!command) return; | 5294 | if (!command) return; |
| 5259 | 5295 | ||
| 5296 | if (command === "thread.copyReference") { | ||
| 5297 | event.preventDefault(); | ||
| 5298 | event.stopPropagation(); | ||
| 5299 | if (!event.repeat) copyActiveThreadReference(); | ||
| 5300 | return; | ||
| 5301 | } | ||
| 5302 | |||
| 5260 | if (command === "thread.settle") { | 5303 | if (command === "thread.settle") { |
| 5261 | event.preventDefault(); | 5304 | event.preventDefault(); |
| 5262 | event.stopPropagation(); | 5305 | event.stopPropagation(); |
| ... | @@ -5426,6 +5469,7 @@ function ChatViewContent(props: ChatViewProps) { | ... | @@ -5426,6 +5469,7 @@ function ChatViewContent(props: ChatViewProps) { |
| 5426 | supportsPinning, | 5469 | supportsPinning, |
| 5427 | supportsSettlement, | 5470 | supportsSettlement, |
| 5428 | confirmAndUnpinThread, | 5471 | confirmAndUnpinThread, |
| 5472 | copyActiveThreadReference, | ||
| 5429 | toggleRightPanel, | 5473 | toggleRightPanel, |
| 5430 | toggleRightPanelMaximized, | 5474 | toggleRightPanelMaximized, |
| 5431 | toggleTerminalVisibility, | 5475 | toggleTerminalVisibility, |
apps/web/src/components/CommandPalette.tsx+90-2| ... | @@ -1,6 +1,10 @@ | ... | @@ -1,6 +1,10 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; | 3 | import { |
| 4 | scopedThreadKey, | ||
| 5 | scopeProjectRef, | ||
| 6 | scopeThreadRef, | ||
| 7 | } from "@t3tools/client-runtime/environment"; | ||
| 4 | import { | 8 | import { |
| 5 | canCreateProjectInEnvironment, | 9 | canCreateProjectInEnvironment, |
| 6 | getCloneDestinationBrowsePath, | 10 | getCloneDestinationBrowsePath, |
| ... | @@ -11,6 +15,7 @@ import { | ... | @@ -11,6 +15,7 @@ import { |
| 11 | } from "@t3tools/client-runtime/operations/projects"; | 15 | } from "@t3tools/client-runtime/operations/projects"; |
| 12 | import { connectionStatusText } from "@t3tools/client-runtime/connection"; | 16 | import { connectionStatusText } from "@t3tools/client-runtime/connection"; |
| 13 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; | 17 | import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; |
| 18 | import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; | ||
| 14 | import { | 19 | import { |
| 15 | canPreloadBrowsePath, | 20 | canPreloadBrowsePath, |
| 16 | createBrowseNavigationCoordinator, | 21 | createBrowseNavigationCoordinator, |
| ... | @@ -65,6 +70,7 @@ import { useAtomValue } from "@effect/atom-react"; | ... | @@ -65,6 +70,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 65 | import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; | 70 | import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; |
| 66 | import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; | 71 | import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; |
| 67 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; | 72 | import { useHandleNewThread } from "../hooks/useHandleNewThread"; |
| 73 | import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; | ||
| 68 | import { useClientSettings } from "../hooks/useSettings"; | 74 | import { useClientSettings } from "../hooks/useSettings"; |
| 69 | import { useTheme } from "../hooks/useTheme"; | 75 | import { useTheme } from "../hooks/useTheme"; |
| 70 | import { readLocalApi } from "../localApi"; | 76 | import { readLocalApi } from "../localApi"; |
| ... | @@ -73,11 +79,13 @@ import { filesystemEnvironment } from "../state/filesystem"; | ... | @@ -73,11 +79,13 @@ import { filesystemEnvironment } from "../state/filesystem"; |
| 73 | import { projectEnvironment } from "../state/projects"; | 79 | import { projectEnvironment } from "../state/projects"; |
| 74 | import { useEnvironmentQuery } from "../state/query"; | 80 | import { useEnvironmentQuery } from "../state/query"; |
| 75 | import { sourceControlEnvironment } from "../state/sourceControl"; | 81 | import { sourceControlEnvironment } from "../state/sourceControl"; |
| 82 | import { vcsEnvironment } from "../state/vcs"; | ||
| 76 | import { useAtomCommand } from "../state/use-atom-command"; | 83 | import { useAtomCommand } from "../state/use-atom-command"; |
| 77 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; | 84 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 78 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; | 85 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 79 | import { useProjects, useThreadShells } from "../state/entities"; | 86 | import { useProject, useProjects, useThreadShells } from "../state/entities"; |
| 80 | import { useThreadSearch } from "../state/queries"; | 87 | import { useThreadSearch } from "../state/queries"; |
| 88 | import * as ThreadPr from "./ThreadStatusIndicators"; | ||
| 81 | import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; | 89 | import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 82 | import { | 90 | import { |
| 83 | appendBrowsePathSegment, | 91 | appendBrowsePathSegment, |
| ... | @@ -592,6 +600,65 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -592,6 +600,65 @@ function OpenCommandPaletteDialog(props: { |
| 592 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = | 600 | const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = |
| 593 | useHandleNewThread(); | 601 | useHandleNewThread(); |
| 594 | const projects = useProjects(); | 602 | const projects = useProjects(); |
| 603 | const changeRequestSnapshotByKey = useAtomValue(ThreadPr.threadChangeRequestSnapshotsAtom); | ||
| 604 | const activeThreadProject = useProject( | ||
| 605 | activeThread === null | ||
| 606 | ? null | ||
| 607 | : scopeProjectRef(activeThread.environmentId, activeThread.projectId), | ||
| 608 | ); | ||
| 609 | const activeThreadCwd = activeThread?.worktreePath ?? activeThreadProject?.workspaceRoot ?? null; | ||
| 610 | const activeThreadGitStatus = useEnvironmentQuery( | ||
| 611 | activeThread != null && | ||
| 612 | activeThread.linkedPullRequest == null && | ||
| 613 | activeThread.branch !== null && | ||
| 614 | activeThreadCwd !== null | ||
| 615 | ? vcsEnvironment.status({ | ||
| 616 | environmentId: activeThread.environmentId, | ||
| 617 | input: { cwd: activeThreadCwd }, | ||
| 618 | }) | ||
| 619 | : null, | ||
| 620 | ).data; | ||
| 621 | const detectedPullRequestUrl = | ||
| 622 | activeThread == null || activeThread.linkedPullRequest != null | ||
| 623 | ? null | ||
| 624 | : (ThreadPr.resolveDisplayedThreadPr({ | ||
| 625 | threadBranch: activeThread.branch, | ||
| 626 | gitStatus: activeThreadGitStatus ?? null, | ||
| 627 | snapshot: changeRequestSnapshotByKey.get( | ||
| 628 | scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)), | ||
| 629 | ), | ||
| 630 | retainTerminalOnBranchMismatch: activeThread.worktreePath === null, | ||
| 631 | })?.url ?? null); | ||
| 632 | const activeThreadReferenceCopyTarget = | ||
| 633 | activeThread == null | ||
| 634 | ? null | ||
| 635 | : resolveThreadReferenceCopyTarget({ | ||
| 636 | threadId: activeThread.id, | ||
| 637 | linkedPullRequestUrl: activeThread.linkedPullRequest?.url ?? null, | ||
| 638 | detectedPullRequestUrl, | ||
| 639 | }); | ||
| 640 | const copyActiveThreadReference = useCallback(async () => { | ||
| 641 | const target = activeThreadReferenceCopyTarget; | ||
| 642 | if (target === null) return; | ||
| 643 | try { | ||
| 644 | const didCopy = await writeTextToClipboard(target.value, target.clipboardTarget); | ||
| 645 | if (!didCopy) return; | ||
| 646 | toastManager.add({ | ||
| 647 | type: "success", | ||
| 648 | title: target.successTitle, | ||
| 649 | description: target.value, | ||
| 650 | }); | ||
| 651 | } catch (error) { | ||
| 652 | console.error(error); | ||
| 653 | toastManager.add( | ||
| 654 | stackedThreadToast({ | ||
| 655 | type: "error", | ||
| 656 | title: target.failureTitle, | ||
| 657 | description: error instanceof Error ? error.message : "An error occurred.", | ||
| 658 | }), | ||
| 659 | ); | ||
| 660 | } | ||
| 661 | }, [activeThreadReferenceCopyTarget]); | ||
| 595 | const projectOrder = useUiStateStore((store) => store.projectOrder); | 662 | const projectOrder = useUiStateStore((store) => store.projectOrder); |
| 596 | const threads = useThreadShells(); | 663 | const threads = useThreadShells(); |
| 597 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); | 664 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| ... | @@ -1529,6 +1596,20 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -1529,6 +1596,20 @@ function OpenCommandPaletteDialog(props: { |
| 1529 | }); | 1596 | }); |
| 1530 | } | 1597 | } |
| 1531 | 1598 | ||
| 1599 | if (activeThreadReferenceCopyTarget !== null) { | ||
| 1600 | actionItems.push({ | ||
| 1601 | kind: "action", | ||
| 1602 | value: "action:copy-thread-reference", | ||
| 1603 | searchTerms: ["copy", "pull request", "pr link", "thread id", "reference"], | ||
| 1604 | title: | ||
| 1605 | activeThreadReferenceCopyTarget.kind === "pull-request" ? "Copy PR link" : "Copy thread ID", | ||
| 1606 | description: activeThreadReferenceCopyTarget.value, | ||
| 1607 | icon: <LinkIcon className={ITEM_ICON_CLASS} />, | ||
| 1608 | shortcutCommand: "thread.copyReference", | ||
| 1609 | run: copyActiveThreadReference, | ||
| 1610 | }); | ||
| 1611 | } | ||
| 1612 | |||
| 1532 | actionItems.push({ | 1613 | actionItems.push({ |
| 1533 | kind: "action", | 1614 | kind: "action", |
| 1534 | value: "action:open-file-picker", | 1615 | value: "action:open-file-picker", |
| ... | @@ -2203,6 +2284,13 @@ function OpenCommandPaletteDialog(props: { | ... | @@ -2203,6 +2284,13 @@ function OpenCommandPaletteDialog(props: { |
| 2203 | return; | 2284 | return; |
| 2204 | } | 2285 | } |
| 2205 | } | 2286 | } |
| 2287 | if (command === "thread.copyReference" && activeThreadReferenceCopyTarget !== null) { | ||
| 2288 | event.preventDefault(); | ||
| 2289 | event.stopPropagation(); | ||
| 2290 | setOpen(false); | ||
| 2291 | void copyActiveThreadReference(); | ||
| 2292 | return; | ||
| 2293 | } | ||
| 2206 | 2294 | ||
| 2207 | if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { | 2295 | if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { |
| 2208 | event.preventDefault(); | 2296 | event.preventDefault(); |
apps/web/src/keybindings.test.ts+31| ... | @@ -142,6 +142,11 @@ const DEFAULT_BINDINGS = compile([ | ... | @@ -142,6 +142,11 @@ const DEFAULT_BINDINGS = compile([ |
| 142 | { shortcut: modShortcut("o"), command: "editor.openFavorite" }, | 142 | { shortcut: modShortcut("o"), command: "editor.openFavorite" }, |
| 143 | { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, | 143 | { shortcut: modShortcut("[", { shiftKey: true }), command: "thread.previous" }, |
| 144 | { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, | 144 | { shortcut: modShortcut("]", { shiftKey: true }), command: "thread.next" }, |
| 145 | { | ||
| 146 | shortcut: modShortcut("c", { shiftKey: true }), | ||
| 147 | command: "thread.copyReference", | ||
| 148 | whenAst: whenNot(whenIdentifier("terminalFocus")), | ||
| 149 | }, | ||
| 145 | { | 150 | { |
| 146 | shortcut: modShortcut("s", { shiftKey: true }), | 151 | shortcut: modShortcut("s", { shiftKey: true }), |
| 147 | command: "thread.settle", | 152 | command: "thread.settle", |
| ... | @@ -213,6 +218,32 @@ describe("settle thread shortcut", () => { | ... | @@ -213,6 +218,32 @@ describe("settle thread shortcut", () => { |
| 213 | }); | 218 | }); |
| 214 | }); | 219 | }); |
| 215 | 220 | ||
| 221 | describe("copy thread reference shortcut", () => { | ||
| 222 | it("resolves Cmd+Shift+C on macOS and Ctrl+Shift+C elsewhere", () => { | ||
| 223 | assert.equal( | ||
| 224 | resolveShortcutCommand(event({ key: "c", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | ||
| 225 | platform: "MacIntel", | ||
| 226 | }), | ||
| 227 | "thread.copyReference", | ||
| 228 | ); | ||
| 229 | assert.equal( | ||
| 230 | resolveShortcutCommand(event({ key: "c", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | ||
| 231 | platform: "Linux", | ||
| 232 | }), | ||
| 233 | "thread.copyReference", | ||
| 234 | ); | ||
| 235 | }); | ||
| 236 | |||
| 237 | it("leaves terminal copy untouched", () => { | ||
| 238 | assert.isNull( | ||
| 239 | resolveShortcutCommand(event({ key: "c", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { | ||
| 240 | platform: "Linux", | ||
| 241 | context: { terminalFocus: true }, | ||
| 242 | }), | ||
| 243 | ); | ||
| 244 | }); | ||
| 245 | }); | ||
| 246 | |||
| 216 | describe("split/new/close terminal shortcuts", () => { | 247 | describe("split/new/close terminal shortcuts", () => { |
| 217 | it("requires terminalFocus for default split/new/close bindings", () => { | 248 | it("requires terminalFocus for default split/new/close bindings", () => { |
| 218 | assert.isFalse( | 249 | assert.isFalse( |
docs/user/keybindings.md+4| ... | @@ -52,6 +52,10 @@ successful pick; its hover glow and badge preview the element and color family t | ... | @@ -52,6 +52,10 @@ successful pick; its hover glow and badge preview the element and color family t |
| 52 | `rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut, | 52 | `rightPanel.toggleMaximized` maximizes or restores the open right panel. It has no default shortcut, |
| 53 | so add one in **Settings** → **Keybindings** if you want to use it. | 53 | so add one in **Settings** → **Keybindings** if you want to use it. |
| 54 | 54 | ||
| 55 | `thread.copyReference` copies the active thread's pull request link, or its thread ID when no pull | ||
| 56 | request is available. Its default shortcut is `mod+shift+c`, and it does not replace terminal copy | ||
| 57 | while the terminal has focus. | ||
| 58 | |||
| 55 | `thread.settle` settles the active thread or restores it when it is already settled. Its default | 59 | `thread.settle` settles the active thread or restores it when it is already settled. Its default |
| 56 | shortcut is `mod+shift+s`, and it does not run while the terminal has focus. | 60 | shortcut is `mod+shift+s`, and it does not run while the terminal has focus. |
| 57 | 61 |
packages/contracts/src/keybindings.test.ts+7| ... | @@ -114,6 +114,13 @@ it.effect("parses keybinding rules", () => | ... | @@ -114,6 +114,13 @@ it.effect("parses keybinding rules", () => |
| 114 | when: "!terminalFocus", | 114 | when: "!terminalFocus", |
| 115 | }); | 115 | }); |
| 116 | assert.strictEqual(parsedThreadSettle.command, "thread.settle"); | 116 | assert.strictEqual(parsedThreadSettle.command, "thread.settle"); |
| 117 | |||
| 118 | const parsedThreadCopyReference = yield* decode(KeybindingRule, { | ||
| 119 | key: "mod+shift+c", | ||
| 120 | command: "thread.copyReference", | ||
| 121 | when: "!terminalFocus", | ||
| 122 | }); | ||
| 123 | assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference"); | ||
| 117 | }), | 124 | }), |
| 118 | ); | 125 | ); |
| 119 | 126 |
packages/contracts/src/keybindings.ts+1| ... | @@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand = | ... | @@ -37,6 +37,7 @@ export type ModelPickerJumpKeybindingCommand = |
| 37 | export const THREAD_KEYBINDING_COMMANDS = [ | 37 | export const THREAD_KEYBINDING_COMMANDS = [ |
| 38 | "thread.previous", | 38 | "thread.previous", |
| 39 | "thread.next", | 39 | "thread.next", |
| 40 | "thread.copyReference", | ||
| 40 | "thread.settle", | 41 | "thread.settle", |
| 41 | "thread.pin", | 42 | "thread.pin", |
| 42 | ...THREAD_JUMP_KEYBINDING_COMMANDS, | 43 | ...THREAD_JUMP_KEYBINDING_COMMANDS, |
packages/shared/package.json+4| ... | @@ -167,6 +167,10 @@ | ... | @@ -167,6 +167,10 @@ |
| 167 | "types": "./src/keybindings.ts", | 167 | "types": "./src/keybindings.ts", |
| 168 | "import": "./src/keybindings.ts" | 168 | "import": "./src/keybindings.ts" |
| 169 | }, | 169 | }, |
| 170 | "./threadReference": { | ||
| 171 | "types": "./src/threadReference.ts", | ||
| 172 | "import": "./src/threadReference.ts" | ||
| 173 | }, | ||
| 170 | "./composerTrigger": { | 174 | "./composerTrigger": { |
| 171 | "types": "./src/composerTrigger.ts", | 175 | "types": "./src/composerTrigger.ts", |
| 172 | "import": "./src/composerTrigger.ts" | 176 | "import": "./src/composerTrigger.ts" |
packages/shared/src/keybindings.ts+1| ... | @@ -46,6 +46,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ | ... | @@ -46,6 +46,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ |
| 46 | { key: "mod+o", command: "editor.openFavorite" }, | 46 | { key: "mod+o", command: "editor.openFavorite" }, |
| 47 | { key: "mod+shift+[", command: "thread.previous" }, | 47 | { key: "mod+shift+[", command: "thread.previous" }, |
| 48 | { key: "mod+shift+]", command: "thread.next" }, | 48 | { key: "mod+shift+]", command: "thread.next" }, |
| 49 | { key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" }, | ||
| 49 | { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" }, | 50 | { key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" }, |
| 50 | { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" }, | 51 | { key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" }, |
| 51 | ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ | 52 | ...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({ |
packages/shared/src/threadReference.test.ts created+41| ... | @@ -0,0 +1,41 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { resolveThreadReferenceCopyTarget } from "./threadReference.ts"; | ||
| 4 | |||
| 5 | describe("resolveThreadReferenceCopyTarget", () => { | ||
| 6 | it("prefers a durable linked pull request", () => { | ||
| 7 | expect( | ||
| 8 | resolveThreadReferenceCopyTarget({ | ||
| 9 | threadId: "thread-1", | ||
| 10 | linkedPullRequestUrl: "https://github.com/t3/pr/12", | ||
| 11 | detectedPullRequestUrl: "https://github.com/t3/pr/13", | ||
| 12 | }), | ||
| 13 | ).toMatchObject({ | ||
| 14 | kind: "pull-request", | ||
| 15 | value: "https://github.com/t3/pr/12", | ||
| 16 | successTitle: "PR link copied", | ||
| 17 | }); | ||
| 18 | }); | ||
| 19 | |||
| 20 | it("uses a pull request detected from the active branch", () => { | ||
| 21 | expect( | ||
| 22 | resolveThreadReferenceCopyTarget({ | ||
| 23 | threadId: "thread-1", | ||
| 24 | detectedPullRequestUrl: "https://github.com/t3/pr/13", | ||
| 25 | }), | ||
| 26 | ).toMatchObject({ | ||
| 27 | kind: "pull-request", | ||
| 28 | value: "https://github.com/t3/pr/13", | ||
| 29 | }); | ||
| 30 | }); | ||
| 31 | |||
| 32 | it("falls back to the thread ID", () => { | ||
| 33 | expect(resolveThreadReferenceCopyTarget({ threadId: "thread-1" })).toEqual({ | ||
| 34 | kind: "thread", | ||
| 35 | value: "thread-1", | ||
| 36 | clipboardTarget: "thread ID", | ||
| 37 | successTitle: "Thread ID copied", | ||
| 38 | failureTitle: "Failed to copy thread ID", | ||
| 39 | }); | ||
| 40 | }); | ||
| 41 | }); | ||
packages/shared/src/threadReference.ts created+30| ... | @@ -0,0 +1,30 @@ | ||
| 1 | export interface ThreadReferenceCopyTarget { | ||
| 2 | readonly kind: "pull-request" | "thread"; | ||
| 3 | readonly value: string; | ||
| 4 | readonly clipboardTarget: string; | ||
| 5 | readonly successTitle: string; | ||
| 6 | readonly failureTitle: string; | ||
| 7 | } | ||
| 8 | |||
| 9 | export function resolveThreadReferenceCopyTarget(input: { | ||
| 10 | readonly threadId: string; | ||
| 11 | readonly linkedPullRequestUrl?: string | null; | ||
| 12 | readonly detectedPullRequestUrl?: string | null; | ||
| 13 | }): ThreadReferenceCopyTarget { | ||
| 14 | const pullRequestUrl = input.linkedPullRequestUrl ?? input.detectedPullRequestUrl; | ||
| 15 | return pullRequestUrl | ||
| 16 | ? { | ||
| 17 | kind: "pull-request", | ||
| 18 | value: pullRequestUrl, | ||
| 19 | clipboardTarget: "pull request link", | ||
| 20 | successTitle: "PR link copied", | ||
| 21 | failureTitle: "Failed to copy PR link", | ||
| 22 | } | ||
| 23 | : { | ||
| 24 | kind: "thread", | ||
| 25 | value: input.threadId, | ||
| 26 | clipboardTarget: "thread ID", | ||
| 27 | successTitle: "Thread ID copied", | ||
| 28 | failureTitle: "Failed to copy thread ID", | ||
| 29 | }; | ||
| 30 | } | ||