feat: add workspace files to chat from Files and Changes
* feat(app): open changed files from context menu * fix(app): preserve changed-file row behavior * feat(app): add changed files to open chats * feat(app): share file actions across explorer and changes * feat(app): attach workspace files to focused chat * feat(app): drag workspace files into chat * refactor(app): use file actions menu for changed files * fix(app): open changed-file actions on right click * test(app): cover direct file attachment flow * fix(app): enable add to chat on native * fix(app): align workspace file pane rows * refactor(app): simplify workspace file attachments * fix(app): preserve uploaded file draft attachments --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
42ee5a5949
commit
bd937850b3
@@ -1,7 +1,8 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import type { AttachmentMetadata, WorkspaceFileComposerAttachment } from "@/attachments/types";
|
||||
import { appendWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
import {
|
||||
garbageCollectAttachments,
|
||||
persistAttachmentFromDataUrl,
|
||||
@@ -39,16 +40,21 @@ interface DraftStoreActions {
|
||||
draftKey: string;
|
||||
lifecycle?: Exclude<DraftLifecycleState, "active">;
|
||||
}) => void;
|
||||
attachWorkspaceFile: (input: {
|
||||
draftKey: string;
|
||||
attachment: WorkspaceFileComposerAttachment;
|
||||
}) => Promise<void>;
|
||||
getCreateModalDraft: () => DraftInput | null;
|
||||
saveCreateModalDraft: (draft: DraftInput | null) => void;
|
||||
beginDraftGeneration: (draftKey: string) => number;
|
||||
isDraftGenerationCurrent: (input: { draftKey: string; generation: number }) => boolean;
|
||||
collectActiveAttachmentIds: () => string[];
|
||||
}
|
||||
|
||||
type DraftStore = DraftStoreState & DraftStoreActions;
|
||||
interface DraftStoreRuntimeState {
|
||||
attachmentFocusRequestByDraftKey: Record<string, number>;
|
||||
}
|
||||
|
||||
type DraftStore = DraftStoreState & DraftStoreRuntimeState & DraftStoreActions;
|
||||
|
||||
const draftGenerations = new Map<string, number>();
|
||||
let gcScheduled = false;
|
||||
const draftPersistStorage = createDraftPersistStorage(
|
||||
createJSONStorage<DraftStoreState>(() => AsyncStorage),
|
||||
@@ -237,6 +243,7 @@ export const useDraftStore = create<DraftStore>()(
|
||||
(set, get) => ({
|
||||
drafts: {},
|
||||
createModalDraft: null,
|
||||
attachmentFocusRequestByDraftKey: {},
|
||||
|
||||
getDraftInput: (draftKey) => {
|
||||
const record = get().drafts[draftKey];
|
||||
@@ -344,7 +351,32 @@ export const useDraftStore = create<DraftStore>()(
|
||||
return { drafts: nextDrafts };
|
||||
});
|
||||
|
||||
draftGenerations.delete(draftKey);
|
||||
scheduleAttachmentGc();
|
||||
},
|
||||
|
||||
attachWorkspaceFile: async ({ draftKey, attachment }) => {
|
||||
await get().hydrateDraftInput({ draftKey });
|
||||
set((state) => {
|
||||
const existing = state.drafts[draftKey];
|
||||
const draft = toDraftInputIfReady(existing) ?? { text: "", attachments: [] };
|
||||
return {
|
||||
drafts: {
|
||||
...state.drafts,
|
||||
[draftKey]: createDraftRecord({
|
||||
draft: {
|
||||
...draft,
|
||||
attachments: appendWorkspaceFileAttachment(draft.attachments, attachment),
|
||||
},
|
||||
lifecycle: "active",
|
||||
previousVersion: existing?.version,
|
||||
}),
|
||||
},
|
||||
attachmentFocusRequestByDraftKey: {
|
||||
...state.attachmentFocusRequestByDraftKey,
|
||||
[draftKey]: (state.attachmentFocusRequestByDraftKey[draftKey] ?? 0) + 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
scheduleAttachmentGc();
|
||||
},
|
||||
|
||||
@@ -369,16 +401,6 @@ export const useDraftStore = create<DraftStore>()(
|
||||
scheduleAttachmentGc();
|
||||
},
|
||||
|
||||
beginDraftGeneration: (draftKey) => {
|
||||
const next = (draftGenerations.get(draftKey) ?? 0) + 1;
|
||||
draftGenerations.set(draftKey, next);
|
||||
return next;
|
||||
},
|
||||
|
||||
isDraftGenerationCurrent: ({ draftKey, generation }) => {
|
||||
return (draftGenerations.get(draftKey) ?? 0) === generation;
|
||||
},
|
||||
|
||||
collectActiveAttachmentIds: () => {
|
||||
return Array.from(collectReferencedAttachmentIdsFromState(get()).values());
|
||||
},
|
||||
@@ -387,6 +409,7 @@ export const useDraftStore = create<DraftStore>()(
|
||||
name: "paseo-drafts",
|
||||
version: DRAFT_STORE_VERSION,
|
||||
storage: draftPersistStorage,
|
||||
partialize: ({ drafts, createModalDraft }) => ({ drafts, createModalDraft }),
|
||||
migrate: (persistedState) => {
|
||||
return migratePersistedState(persistedState, {
|
||||
migrateLegacyImages,
|
||||
|
||||
@@ -71,6 +71,46 @@ describe("draft-store lifecycle", () => {
|
||||
});
|
||||
|
||||
describe("draft-store normalization", () => {
|
||||
it("preserves uploaded file attachments when hydrating a draft", () => {
|
||||
const attachment = {
|
||||
kind: "file" as const,
|
||||
attachment: {
|
||||
type: "uploaded_file" as const,
|
||||
id: "file-1",
|
||||
fileName: "context.json",
|
||||
mimeType: "application/json",
|
||||
size: 42,
|
||||
path: "/tmp/context.json",
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
toDraftInputIfReady({
|
||||
input: { text: "Review this", attachments: [attachment] },
|
||||
lifecycle: "active",
|
||||
updatedAt: 1,
|
||||
version: 1,
|
||||
}),
|
||||
).toEqual({ text: "Review this", attachments: [attachment] });
|
||||
});
|
||||
|
||||
it("preserves workspace file selections when hydrating a draft", () => {
|
||||
const attachment = {
|
||||
kind: "workspace_file" as const,
|
||||
path: "src/app.ts",
|
||||
selection: { kind: "line_range" as const, startLine: 12, endLine: 24 },
|
||||
};
|
||||
|
||||
expect(
|
||||
toDraftInputIfReady({
|
||||
input: { text: "Review this", attachments: [attachment] },
|
||||
lifecycle: "active",
|
||||
updatedAt: 1,
|
||||
version: 1,
|
||||
}),
|
||||
).toEqual({ text: "Review this", attachments: [attachment] });
|
||||
});
|
||||
|
||||
it("preserves New Workspace picker ownership when hydrating a draft", () => {
|
||||
const pickerAttachment = {
|
||||
kind: "github_pr" as const,
|
||||
|
||||
@@ -3,7 +3,12 @@ import {
|
||||
type AttachmentMetadata,
|
||||
type UserComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import { ForgeSearchItemSchema, GitHubSearchItemSchema } from "@getpaseo/protocol/messages";
|
||||
import { isWorkspaceFileComposerAttachment } from "@/attachments/workspace-file";
|
||||
import {
|
||||
ForgeSearchItemSchema,
|
||||
GitHubSearchItemSchema,
|
||||
UploadedFileAttachmentSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
|
||||
export const DRAFT_STORE_VERSION = 5;
|
||||
export const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
|
||||
@@ -83,6 +88,12 @@ export function isUserComposerAttachment(value: unknown): value is UserComposerA
|
||||
const metadata = record.metadata;
|
||||
return isAttachmentMetadata(metadata);
|
||||
}
|
||||
if (record.kind === "workspace_file") {
|
||||
return isWorkspaceFileComposerAttachment(value);
|
||||
}
|
||||
if (record.kind === "file") {
|
||||
return UploadedFileAttachmentSchema.safeParse(record.attachment).success;
|
||||
}
|
||||
if (
|
||||
record.kind !== "forge_issue" &&
|
||||
record.kind !== "forge_change_request" &&
|
||||
@@ -113,6 +124,13 @@ export function normalizeComposerAttachment(
|
||||
metadata: normalizeAttachmentMetadata(attachment.metadata),
|
||||
};
|
||||
}
|
||||
if (attachment.kind === "workspace_file") {
|
||||
return {
|
||||
kind: "workspace_file",
|
||||
path: attachment.path.trim().replace(/^\.\//, ""),
|
||||
selection: attachment.selection,
|
||||
};
|
||||
}
|
||||
if (attachment.kind === "github_pr") {
|
||||
const item =
|
||||
(attachment.item as { kind: string }).kind === "pr"
|
||||
|
||||
Reference in New Issue
Block a user