Skip to content
Open
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
25 changes: 24 additions & 1 deletion apps/desktop/electron/main/plugin-panel-host.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { BrowserWindow, ipcMain, Menu, session, systemPreferences } from "electron";
import {
BrowserWindow,
ipcMain,
Menu,
session,
systemPreferences,
} from "electron";
import { pathToFileURL } from "node:url";
import { join, resolve } from "node:path";
import { catalogs, resolveLocale } from "@pi-desktop/i18n";
Expand Down Expand Up @@ -176,6 +182,7 @@ export class PluginPanelHost {
private senderResolvers: Array<(senderId: number) => string | null> = [];
/** Observer for failures of the fire-and-forget legacy sync bridge. */
private onBridgeError?: (pluginId: string, channel: string, error: unknown) => void;
private onComposerFileDrop?: (payload: { data: string; x: number; y: number }) => void;
/**
* Locale per floating-widget web contents. Presence in this map is also what
* makes a window a widget for `showWidgetMenu`, so a panel never gets a
Expand All @@ -187,10 +194,12 @@ export class PluginPanelHost {
bridge: BridgeHandler,
onBlockedRequest?: PluginPanelBlockedRequest,
onBridgeError?: (pluginId: string, channel: string, error: unknown) => void,
onComposerFileDrop?: (payload: { data: string; x: number; y: number }) => void,
) {
this.bridge = bridge;
this.onBlockedRequest = onBlockedRequest;
this.onBridgeError = onBridgeError;
this.onComposerFileDrop = onComposerFileDrop;
this.ensureHandlers();
}

Expand Down Expand Up @@ -230,6 +239,20 @@ export class PluginPanelHost {
);
});

ipcMain.on("pi-plugin-panel-composer-file-drop", (event, rawDrop: unknown) => {
const senderId = event.sender.id;
if (!this.pluginIdForSender(senderId) || !rawDrop || typeof rawDrop !== "object") return;
const { data, screenX, screenY } = rawDrop as {
data?: unknown;
screenX?: unknown;
screenY?: unknown;
};
if (typeof data !== "string" || !data || data.length > 4_096) return;
if (typeof screenX !== "number" || !Number.isFinite(screenX)) return;
if (typeof screenY !== "number" || !Number.isFinite(screenY)) return;
this.onComposerFileDrop?.({ data, x: screenX, y: screenY });
});

// Legacy sync bridge used by older sample panels.
ipcMain.on(
"pi-plugin-panel-bridge",
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/electron/main/services/plugin-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,15 @@ export function createPluginServices({
data: { channel, error: String(error) },
});
},
({ data, x, y }) => {
const bounds = getMainWindow()?.getContentBounds();
if (!bounds) return;
sendToRenderer(IPC.event.pluginComposerFileDrop, {
data,
clientX: x - bounds.x,
clientY: y - bounds.y,
});
},
);
const callPluginSessionHost = async (
method: string,
Expand Down
36 changes: 36 additions & 0 deletions apps/desktop/electron/preload/plugin-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,42 @@ const bridge = {

contextBridge.exposeInMainWorld("pluginBridge", bridge);

const COMPOSER_WORKSPACE_FILE_MIME = "application/x-pi-desktop-workspace-file";
let composerFileDrag: string | null = null;

// A docked plugin view is a separate WebContentsView, so Chromium does not
// carry its HTML drop into the host renderer. Read the private payload while
// the event bubbles, after the plugin has populated it, then forward the final
// screen position so the host renderer can accept only a Composer drop.
window.addEventListener(
"dragstart",
(event) => {
composerFileDrag = null;
if (!event.isTrusted) return;
const data = event.dataTransfer;
if (!data?.types.includes(COMPOSER_WORKSPACE_FILE_MIME)) return;
const raw = data.getData(COMPOSER_WORKSPACE_FILE_MIME);
if (raw && raw.length <= 4_096) {
composerFileDrag = raw;
}
},
);

window.addEventListener(
"dragend",
(event) => {
const data = composerFileDrag;
composerFileDrag = null;
if (!event.isTrusted || !data) return;
ipcRenderer.send("pi-plugin-panel-composer-file-drop", {
data,
screenX: event.screenX,
screenY: event.screenY,
});
},
true,
);

// Record the gesture before page code handles it. The host consumes one of
// these short-lived paths when the panel asks for fs.registerDropped.
window.addEventListener(
Expand Down
8 changes: 7 additions & 1 deletion apps/desktop/resources/plugins/pi.file-manager/UPSTREAM.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ directory carries the built view the plugin publishes, not its React source.

## Local changes

Two, so a re-sync stays a copy:
Three, so a re-sync stays reproducible:

- `manifest.json` gains `"license": "MIT"` (after `author`), making the vendored
copy 14191 bytes
Expand All @@ -62,6 +62,12 @@ Two, so a re-sync stays a copy:
ancestor's `"module"`, which is why the marker cannot live one directory up.
In a packaged app the file is inert, and deleting it only costs the developer
experience, never a user.
- `views/assets/index.js` is rebuilt from the tagged `views-src/` with one
additive tree-row change: regular, non-symlink files publish
`application/x-pi-desktop-workspace-file` with their workspace-relative path
and leaf name during an HTML drag. Directories and symlinks remain
non-draggable. The local bundle is 1345647 bytes with sha256
`89c2b949b605a934a3131bc614dfc670046bd2cb502db81b3fec255b202c5e45`.

## Re-syncing a newer release

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
useCallback,
useEffect,
useState,
useRef,
type ClipboardEvent,
Expand All @@ -14,8 +16,12 @@ import {
} from "../../../../lib/composer-draft-cache";
import {
composerDropItems,
composerWorkspaceFileDrop,
createComposerWorkspaceDropDeduper,
hasComposerFileDrag,
parseComposerWorkspaceFileDrop,
type ComposerDropItem,
type ComposerWorkspaceFileDrop,
} from "../../../../lib/composer-drop";
import type { ComposerDraftSnapshot } from "../../../../lib/composer-smart-stop";
import {
Expand Down Expand Up @@ -81,13 +87,67 @@ export function useComposerAttachments({
}: UseComposerAttachmentsOptions): ComposerAttachmentsController {
const [pasting, setPasting] = useState(false);
const pickerInFlight = useRef(false);
const acceptWorkspaceDrop = useRef(createComposerWorkspaceDropDeduper());
const [dropTargetActive, setDropTargetActive] = useState(false);
const [droppedDirectories, setDroppedDirectories] = useState<ComposerDropItem[]>([]);
const isInputBlocked = inputBlocked || pasting;

const snapshotReferences = (sourceSessionId: string) =>
draft.snapshotReferences(sourceSessionId);

const attachWorkspaceFile = useCallback(
(workspaceFile: ComposerWorkspaceFileDrop) => {
if (!acceptWorkspaceDrop.current(workspaceFile)) return;
const editor = draft.ref.current;
const sourceValue = editor ? readEditorValue(editor) : draft.valueRef.current;
const { start: selectionStart, end: selectionEnd } = editor
? editorSelectionRange(editor)
: { start: sourceValue.length, end: sourceValue.length };
const token = nextChipToken();
const sessionId = activeSessionId ?? "";
const reference = createFileReference(workspaceFile.path, workspaceFile.name, sessionId, {
token,
});
const previousReferences = draft.snapshotReferences(sessionId);
const nextText =
sourceValue.slice(0, selectionStart) + token + sourceValue.slice(selectionEnd);
const nextReferences = [
...previousReferences.map((item) =>
createFileReference(item.path, item.name, sessionId, item),
),
reference,
];
writeComposerDraft(draftKey, {
text: nextText,
fileReferences: [...previousReferences, toDraftReference(reference)],
});
draft.applyEditorDraft(nextText, nextReferences, selectionStart + token.length);
showToast(t, "chat.filesAttached", { count: 1 }, "success");
},
[activeSessionId, draft, draftKey, t],
);

useEffect(
() =>
api.onPluginComposerFileDrop(({ data, clientX, clientY }) => {
if (isInputBlocked || !Number.isFinite(clientX) || !Number.isFinite(clientY)) return;
const shell = draft.ref.current?.closest<HTMLElement>(".composer-shell");
if (!shell) return;
const bounds = shell.getBoundingClientRect();
if (
clientX < bounds.left ||
clientX > bounds.right ||
clientY < bounds.top ||
clientY > bounds.bottom
) {
return;
}
const workspaceFile = parseComposerWorkspaceFileDrop(data);
if (workspaceFile) attachWorkspaceFile(workspaceFile);
}),
[attachWorkspaceFile, draft.ref, isInputBlocked],
);

const pickAndAttach = async () => {
// The ref closes the gap before React re-renders the disabled button.
if (pickerInFlight.current || isInputBlocked) return;
Expand Down Expand Up @@ -401,6 +461,11 @@ export function useComposerAttachments({
event.preventDefault();
setDropTargetActive(false);
if (isInputBlocked) return;
const workspaceFile = composerWorkspaceFileDrop(event.dataTransfer);
if (workspaceFile) {
attachWorkspaceFile(workspaceFile);
return;
}
const items = composerDropItems(event.dataTransfer, api.getDroppedFilePath);
const directories = items.filter((item) => item.isDirectory);
const files = items.filter((item) => !item.isDirectory);
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1441,6 +1441,14 @@ export const api = {
),
);
},
onPluginComposerFileDrop: (
listener: (event: { data: string; clientX: number; clientY: number }) => void,
) => {
if (!window.piDesktop?.on) return () => undefined;
return window.piDesktop.on(IPC.event.pluginComposerFileDrop, (payload) =>
listener(payload as { data: string; clientX: number; clientY: number }),
);
},
onNotificationActivated: (
listener: (event: { id: string; sessionId: string }) => void,
) => {
Expand Down
53 changes: 53 additions & 0 deletions apps/desktop/src/lib/composer-drop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,27 @@ export type ComposerDropItem = {
isDirectory: boolean;
};

export type ComposerWorkspaceFileDrop = {
path: string;
name: string;
};

/** Suppress the direct drop and forwarded drag-end copies of one gesture. */
export function createComposerWorkspaceDropDeduper(windowMs = 500) {
let lastKey = "";
let lastReceivedAt = Number.NEGATIVE_INFINITY;
return (drop: ComposerWorkspaceFileDrop, receivedAt = Date.now()): boolean => {
const key = `${drop.path}\0${drop.name}`;
if (key === lastKey && receivedAt - lastReceivedAt < windowMs) return false;
lastKey = key;
lastReceivedAt = receivedAt;
return true;
};
}

export const COMPOSER_WORKSPACE_FILE_MIME =
"application/x-pi-desktop-workspace-file";

type DataTransferItemWithEntry = DataTransferItem & {
webkitGetAsEntry?: () => FileSystemEntry | null;
};
Expand All @@ -21,11 +42,43 @@ function isDirectoryItem(item: DataTransferItemWithEntry, file: File): boolean {
/** True when the data transfer contains native files or directories. */
export function hasComposerFileDrag(data: DataTransfer): boolean {
return (
Array.from(data.types ?? []).includes(COMPOSER_WORKSPACE_FILE_MIME) ||
data.files.length > 0 ||
Array.from(data.items).some((item) => item.kind === "file")
);
}

/** Parse and validate the private file-tree drag payload. */
export function parseComposerWorkspaceFileDrop(
raw: string,
): ComposerWorkspaceFileDrop | null {
try {
const value = JSON.parse(raw) as { path?: unknown; name?: unknown };
if (typeof value.path !== "string" || typeof value.name !== "string") return null;
const path = value.path.trim().replace(/\\/g, "/");
const name = value.name.trim();
if (
!path ||
!name ||
path.startsWith("/") ||
/^[A-Za-z]:\//.test(path) ||
path.split("/").some((part) => part === "..")
) {
return null;
}
return { path, name };
} catch {
return null;
}
}

export function composerWorkspaceFileDrop(
data: DataTransfer,
): ComposerWorkspaceFileDrop | null {
if (!Array.from(data.types ?? []).includes(COMPOSER_WORKSPACE_FILE_MIME)) return null;
return parseComposerWorkspaceFileDrop(data.getData(COMPOSER_WORKSPACE_FILE_MIME));
}

/**
* Return real dropped items in OS order. Electron's preload resolves the
* source path; ordinary files can still be saved from their bytes when a
Expand Down
Loading
Loading