Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions apps/mobile/src/components/CheckoutPickerSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import type { EnvironmentId } from "@t3tools/contracts";
import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences";
import {
checkoutAccessForDriver,
checkoutCandidates,
checkoutContextRecord,
checkoutDisplayName,
checkoutPathKey,
looksLikeCheckoutPath,
rankCheckoutCandidates,
} from "@t3tools/shared/checkouts";
import { useMemo, useState, type ReactNode } from "react";
import { Alert, FlatList, Pressable, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../state/entities";
import { useDebouncedValue } from "../state/queries";
import { useEnvironmentQuery } from "../state/query";
import { sourceControlEnvironment } from "../state/sourceControl";
import {
captureComposerDraftInsertion,
insertComposerDraftContext,
type ComposerDraftInsertion,
} from "../state/use-composer-drafts";
import { AppText as Text, AppTextInput } from "./AppText";
import { PickerSheet } from "./PickerSheet";

export interface CheckoutPickerTarget {
readonly environmentId: EnvironmentId;
readonly draftKey: string;
readonly driver: string | null;
}

type OpenedCheckoutPicker = CheckoutPickerTarget & {
readonly insertion: ComposerDraftInsertion;
};

/** Picks a local checkout to reference in the draft. The owner renders `sheet`. */
export function useCheckoutPicker(target: CheckoutPickerTarget | null): {
readonly open: () => void;
readonly sheet: ReactNode;
} {
const [opened, setOpened] = useState<OpenedCheckoutPicker | null>(null);
return {
open: () => {
if (target)
setOpened({ ...target, insertion: captureComposerDraftInsertion(target.draftKey) });
},
sheet: opened ? <CheckoutPickerSheet {...opened} onClose={() => setOpened(null)} /> : null,
};
}

function CheckoutPickerSheet(props: OpenedCheckoutPicker & { readonly onClose: () => void }) {
const insets = useSafeAreaInsets();
const projects = useProjects();
const threads = useThreadShells();
const config = useEnvironmentServerConfig(props.environmentId);
const [query, setQuery] = useState("");
const candidates = useMemo(
() =>
checkoutCandidates({
currentEnvironmentId: props.environmentId,
projects,
worktrees: threads.map((thread) => ({
environmentId: thread.environmentId,
worktreePath: thread.worktreePath,
})),
environmentLabel: (id) =>
id === props.environmentId
? (config?.environment.label ?? "This environment")
: "Another environment",
}).filter((candidate) => candidate.selectable),
[config?.environment.label, projects, props.environmentId, threads],
);
const ranked = useMemo(
() => rankCheckoutCandidates(candidates, looksLikeCheckoutPath(query) ? "" : query, new Map()),
[candidates, query],
);
const pasted = looksLikeCheckoutPath(query) ? checkoutPathKey(query.trim()) : null;
const paths = useMemo(() => {
const listed = ranked.slice(0, 20).map((candidate) => candidate.path);
if (pasted !== null && !listed.includes(pasted)) listed.unshift(pasted);
return listed;
}, [pasted, ranked]);
const settled = useDebouncedValue(paths.join("\n"), 200);
const inspection = useEnvironmentQuery(
settled.length === 0
? null
: sourceControlEnvironment.checkouts({
environmentId: props.environmentId,
input: { paths: settled.split("\n") },
}),
);
const rows = useMemo(() => {
const listed = ranked.slice(0, 20);
if (pasted === null || listed.some((candidate) => checkoutPathKey(candidate.path) === pasted)) {
return listed;
}
return [
{
environmentId: props.environmentId,
environmentLabel: "",
selectable: true,
path: pasted,
name: checkoutDisplayName(pasted),
kind: "project" as const,
},
...listed,
];
}, [pasted, props.environmentId, ranked]);

const attach = (path: string, name: string) => {
const entry = inspection.data?.entries.find(
(candidate) => checkoutPathKey(candidate.path) === checkoutPathKey(path),
);
const record = checkoutContextRecord({
path: entry?.path ?? path,
name,
branch: entry?.branch ?? null,
headSha: entry?.headSha ?? null,
changedFiles: entry?.changedFiles ?? 0,
ahead: entry?.ahead ?? 0,
access: checkoutAccessForDriver(props.driver),
environmentId: props.environmentId,
});
if (
!insertComposerDraftContext(
props.draftKey,
{
text: formatComposerContextReference(record),
context: { version: 1, records: [record] },
},
props.insertion,
)
) {
Alert.alert("Too many context items", "Remove some context from the draft and try again.");
return;
}
props.onClose();
};

return (
<PickerSheet title="Attach checkout" onClose={props.onClose}>
<View className="px-4 pb-3">
<AppTextInput
accessibilityLabel="Search checkouts"
autoFocus
autoCapitalize="none"
autoCorrect={false}
clearButtonMode="while-editing"
placeholder="Search by name or paste a path"
value={query}
onChangeText={setQuery}
/>
</View>
<FlatList
data={rows}
keyExtractor={(row) => row.path}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 16) }}
ListEmptyComponent={
<View className="items-center p-6">
<Text className="text-center text-foreground-muted">
{pasted
? "Press return to use that path."
: "No local checkouts yet. Paste a path to use a folder."}
</Text>
</View>
}
renderItem={({ item }) => {
const entry = inspection.data?.entries.find(
(candidate) => checkoutPathKey(candidate.path) === checkoutPathKey(item.path),
);
const status =
entry === undefined
? item.path
: `${entry.branch ?? "detached"} · ${entry.changedFiles === 0 ? "clean" : `${entry.changedFiles} changed`}`;
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Attach ${item.name}`}
onPress={() => attach(item.path, item.name)}
className="gap-0.5 px-4 py-3 active:bg-subtle"
>
<Text className="text-base text-foreground" numberOfLines={1}>
{item.name}
</Text>
<Text className="text-sm text-foreground-muted" numberOfLines={1}>
{status}
</Text>
</Pressable>
);
}}
/>
</PickerSheet>
);
}
10 changes: 10 additions & 0 deletions apps/mobile/src/components/ComposerAttachmentButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ const GITHUB_ISSUE_ACTION: MenuAction = {
const NOTION_ACTION: MenuAction = { id: "notion", title: "Notion page", image: "doc.text" };
const GRANOLA_ACTION: MenuAction = { id: "granola", title: "Granola note", image: "waveform" };
const SLACK_ACTION: MenuAction = { id: "slack", title: "Slack message", image: "number" };
const CHECKOUT_ACTION: MenuAction = {
id: "checkout",
title: "Checkout",
image: "laptopcomputer",
};
const REPOSITORY_ACTION: MenuAction = {
id: "repository",
title: "Repository",
Expand All @@ -37,6 +42,8 @@ export function ComposerAttachmentButton(props: {
readonly onPickGranolaNote?: () => void;
/** Omit to hide the Slack action. See `useSlackMessagePicker`. */
readonly onPickSlackMessage?: () => void;
/** Omit to hide the checkout action. See `useCheckoutPicker`. */
readonly onPickCheckout?: () => void;
/** Omit to hide the repository action. See `useRepositoryPicker`. */
readonly onPickRepository?: () => void;
}) {
Expand All @@ -49,6 +56,7 @@ export function ComposerAttachmentButton(props: {
...(props.onPickNotionPage ? [NOTION_ACTION] : []),
...(props.onPickGranolaNote ? [GRANOLA_ACTION] : []),
...(props.onPickSlackMessage ? [SLACK_ACTION] : []),
...(props.onPickCheckout ? [CHECKOUT_ACTION] : []),
...(props.onPickRepository ? [REPOSITORY_ACTION] : []),
];
const usesMenu = !props.disabled && actions.length > 1;
Expand Down Expand Up @@ -96,6 +104,8 @@ export function ComposerAttachmentButton(props: {
props.onPickGranolaNote?.();
} else if (nativeEvent.event === "slack") {
props.onPickSlackMessage?.();
} else if (nativeEvent.event === "checkout") {
props.onPickCheckout?.();
} else if (nativeEvent.event === "repository") {
props.onPickRepository?.();
}
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
import { useGranolaNotePicker } from "../../components/GranolaNotePickerSheet";
import { useNotionPagePicker } from "../../components/NotionPagePickerSheet";
import { useSlackMessagePicker } from "../../components/SlackMessagePickerSheet";
import { useCheckoutPicker } from "../../components/CheckoutPickerSheet";
import { useRepositoryPicker } from "../../components/RepositoryPickerSheet";
import { ControlPillMenu } from "../../components/ControlPillMenu";
import { gitHubIssueEnvironment } from "../../state/githubIssues";
Expand Down Expand Up @@ -1105,6 +1106,15 @@ export function NewTaskDraftScreen(props: {
}
: null,
);
const checkoutPicker = useCheckoutPicker(
flow.draftKey && selectedProject
? {
environmentId: selectedProject.environmentId,
draftKey: flow.draftKey,
driver: flow.selectedProviderStatus?.driver ?? null,
}
: null,
);
const repositoryPicker = useRepositoryPicker(
flow.draftKey && selectedProject
? {
Expand Down Expand Up @@ -1954,6 +1964,7 @@ export function NewTaskDraftScreen(props: {
? slackMessagePicker.open
: undefined
}
onPickCheckout={flow.draftKey ? checkoutPicker.open : undefined}
onPickRepository={flow.draftKey ? repositoryPicker.open : undefined}
/>
<View className="min-w-0 flex-1 flex-row items-center justify-end gap-2">
Expand Down Expand Up @@ -2045,6 +2056,7 @@ export function NewTaskDraftScreen(props: {
{notionPagePicker.sheet}
{granolaNotePicker.sheet}
{startFromPullRequestPicker.sheet}
{checkoutPicker.sheet}
{repositoryPicker.sheet}
</View>
);
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import { useGitHubIssuePicker } from "../../components/GitHubIssuePickerSheet";
import { useGranolaNotePicker } from "../../components/GranolaNotePickerSheet";
import { useNotionPagePicker } from "../../components/NotionPagePickerSheet";
import { useSlackMessagePicker } from "../../components/SlackMessagePickerSheet";
import { useCheckoutPicker } from "../../components/CheckoutPickerSheet";
import { useRepositoryPicker } from "../../components/RepositoryPickerSheet";
import {
ComposerAttachmentStrip,
Expand Down Expand Up @@ -490,6 +491,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
? { environmentId: props.environmentId, draftKey: composerDraftKey, cwd: workspaceCwd }
: null,
);
const checkoutPicker = useCheckoutPicker({
environmentId: props.environmentId,
draftKey: composerDraftKey,
driver: selectedProviderStatus?.driver ?? null,
});
const repositoryPicker = useRepositoryPicker({
environmentId: props.environmentId,
draftKey: composerDraftKey,
Expand Down Expand Up @@ -929,6 +935,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
onPickNotionPage={notionPagePicker.open}
onPickGranolaNote={granolaNotePicker.open}
onPickSlackMessage={slackMessagePicker.open}
onPickCheckout={checkoutPicker.open}
onPickRepository={repositoryPicker.open}
/>
) : null}
Expand Down Expand Up @@ -1213,6 +1220,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
onPickNotionPage={notionPagePicker.open}
onPickGranolaNote={granolaNotePicker.open}
onPickSlackMessage={slackMessagePicker.open}
onPickCheckout={checkoutPicker.open}
onPickRepository={repositoryPicker.open}
/>
<View className="min-w-0 shrink">
Expand Down Expand Up @@ -1278,6 +1286,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
{slackMessagePicker.sheet}
{notionPagePicker.sheet}
{granolaNotePicker.sheet}
{checkoutPicker.sheet}
{repositoryPicker.sheet}
</Animated.View>
);
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/lib/composerChipPresentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const CONTEXT_CHIP_PRESENTATIONS = {
"github-issue": { accent: "#009f6e", symbol: "smallcircle.filled.circle" },
"slack-thread": { accent: "#c6589c", symbol: "number" },
repository: { accent: "#009c9c", symbol: "folder" },
checkout: { accent: "#0096af", symbol: "laptopcomputer" },
} as const;

/**
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.contextRepositoriesList]: AuthOrchestrationReadScope,
[WS_METHODS.contextRepositoriesInspect]: AuthOrchestrationReadScope,
[WS_METHODS.contextRepositoriesRemove]: AuthSourceControlWriteScope,
[WS_METHODS.checkoutsInspect]: AuthOrchestrationReadScope,
// Reading issues is a read; connecting or disconnecting the environment's
// Linear account changes what every client of this environment can reach.
[WS_METHODS.linearSubscribeState]: AuthOrchestrationReadScope,
Expand Down
Loading