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
20 changes: 20 additions & 0 deletions src/modules/ai/components/AiInputBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Button } from "@/components/ui/button";
import { Popover, PopoverAnchor } from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
import { readPathDragPayload, TERAX_PATH_MIME } from "@/modules/explorer/lib/dragPayload";
import {
Cancel01Icon,
CodeIcon,
Expand Down Expand Up @@ -92,6 +93,10 @@ export function AiInputBar() {

const pickerOpen = trigger !== null;

const acceptsDrop = (dataTransfer: DataTransfer) =>
Array.from(dataTransfer.types).includes(TERAX_PATH_MIME) ||
dataTransfer.files.length > 0;

const onPickItem = (item: PickerItem) => {
if (!trigger) return;
const before = c.value.slice(0, trigger.start);
Expand Down Expand Up @@ -136,6 +141,21 @@ export function AiInputBar() {
"flex flex-col gap-1.5 rounded-lg px-1 py-1",
"transition-colors focus-within:border-border",
)}
onDragOver={(e) => {
if (!acceptsDrop(e.dataTransfer)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}}
onDrop={(e) => {
if (!acceptsDrop(e.dataTransfer)) return;
e.preventDefault();
const paths = readPathDragPayload(e.dataTransfer);
if (paths.length > 0) {
for (const path of paths) void c.attachFileByPath(path);
return;
}
void c.addFiles(e.dataTransfer.files);
}}
>
<ChipsRow
files={c.files}
Expand Down
69 changes: 42 additions & 27 deletions src/modules/ai/lib/composer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import {
createContext,
useContext,
Expand All @@ -11,7 +10,7 @@ import { expandSnippetTokens, type Snippet } from "../lib/snippets";
import { tryRunSlashCommand, type SlashCommandMeta } from "./slashCommands";
import { getOrCreateChat, useChatStore } from "../store/chatStore";
import { useSnippetsStore } from "../store/snippetsStore";
import { currentWorkspaceEnv } from "@/modules/workspace";
import { native, type DirEntry } from "./native";

export type FileAttachment = {
id: string;
Expand Down Expand Up @@ -172,40 +171,48 @@ export function AiComposerProvider({ children }: ProviderProps) {

const attachFileByPath = async (path: string) => {
try {
type ReadResult =
| { kind: "text"; content: string; size: number }
| { kind: "binary"; size: number }
| { kind: "toolarge"; size: number; limit: number };
const result = await invoke<ReadResult>("fs_read_file", {
path,
workspace: currentWorkspaceEnv(),
});
const result = await native.readFile(path);
if (result.kind !== "text") {
// Binary/oversize files: skip (could surface a toast in future).
console.warn("attachFileByPath: skipped non-text file", path, result);
return;
}
const name = path.split("/").pop() || path;
const id = `path-${path}`;
setFiles((prev) => {
if (prev.some((f) => f.id === id)) return prev;
const att: FileAttachment = {
id,
name,
kind: "text",
mediaType: "text/plain",
text: result.content,
size: result.size,
};
return [...prev, att];
});
// Open the AI panel & focus the input so the user sees the chip.
useChatStore.getState().focusInput();
addPathAttachment(path, result.content, result.size);
} catch (e) {
console.error("attachFileByPath failed:", e);
try {
const entries = await native.readDir(path);
const text = formatDirectoryAttachment(path, entries);
addPathAttachment(path, text, text.length, "Directory");
} catch {
console.error("attachFileByPath failed:", e);
}
}
};

const addPathAttachment = (
path: string,
text: string,
size: number,
fallbackName?: string,
) => {
const name = path.split("/").pop() || fallbackName || path;
const id = `path-${path}`;
setFiles((prev) => {
if (prev.some((f) => f.id === id)) return prev;
const att: FileAttachment = {
id,
name,
kind: "text",
mediaType: "text/plain",
text,
size,
};
return [...prev, att];
});
// Open the AI panel & focus the input so the user sees the chip.
useChatStore.getState().focusInput();
};

const submit = () => {
if (isBusy) return;
const trimmed = value.trim();
Expand Down Expand Up @@ -377,3 +384,11 @@ function readAsDataURL(file: Blob): Promise<string> {
reader.readAsDataURL(file);
});
}

function formatDirectoryAttachment(path: string, entries: DirEntry[]): string {
const lines = entries.map((entry) => {
const suffix = entry.kind === "dir" ? "/" : "";
return `${entry.kind.padEnd(7)} ${entry.name}${suffix}`;
});
return [`Directory: ${path}`, ...lines].join("\n");
}
10 changes: 10 additions & 0 deletions src/modules/explorer/FileTreeNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
relativePath,
revealInFinder,
} from "./lib/contextActions";
import { writePathDragPayload } from "./lib/dragPayload";
import { fileIconUrl, folderIconUrl } from "./lib/iconResolver";
import { COMPACT_CONTENT, COMPACT_ITEM } from "./lib/menuItemClass";
import type { DirEntry, useFileTree } from "./lib/useFileTree";
Expand Down Expand Up @@ -70,6 +71,13 @@ function FileTreeNodeImpl({
else onOpenFile(path);
}, [isDir, path, tree, onOpenFile, onSelectPath]);

const handleDragStart = useCallback(
(e: React.DragEvent<HTMLButtonElement>) => {
writePathDragPayload(e.dataTransfer, path);
},
[path],
);

const isSelected = selectedPath === path;

const pendingInThisDir =
Expand Down Expand Up @@ -106,6 +114,8 @@ function FileTreeNodeImpl({
<button
type="button"
data-fs-path={path}
draggable
onDragStart={handleDragStart}
onClick={handleClick}
onDoubleClick={() => !isDir && tree.beginRename(path)}
className={cn(
Expand Down
28 changes: 28 additions & 0 deletions src/modules/explorer/lib/dragPayload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export const TERAX_PATH_MIME = "application/x-terax-path";

export function writePathDragPayload(
dataTransfer: DataTransfer,
path: string,
): void {
dataTransfer.setData("text/plain", path);
dataTransfer.setData(TERAX_PATH_MIME, path);
dataTransfer.effectAllowed = "copyLink";
}

export function readPathDragPayload(dataTransfer: DataTransfer): string[] {
const custom = dataTransfer.getData(TERAX_PATH_MIME);
if (custom) return splitPathPayload(custom);

if (Array.from(dataTransfer.types).includes(TERAX_PATH_MIME)) {
return [];
}

return splitPathPayload(dataTransfer.getData("text/plain"));
}

function splitPathPayload(payload: string): string[] {
return payload
.split(/\r?\n/)
.map((path) => path.trim())
.filter(Boolean);
}
23 changes: 22 additions & 1 deletion src/modules/terminal/TerminalPane.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useTheme } from "@/modules/theme";
import type { SearchAddon } from "@xterm/addon-search";
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
import { readPathDragPayload, TERAX_PATH_MIME } from "@/modules/explorer/lib/dragPayload";
import { shellQuoteAll } from "./lib/shellQuote";
import { useTerminalSession } from "./lib/useTerminalSession";

export type TerminalPaneHandle = {
Expand Down Expand Up @@ -37,6 +39,7 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, Props>(
ref,
) {
const containerRef = useRef<HTMLDivElement>(null);
const [isPathDragOver, setIsPathDragOver] = useState(false);
const { resolvedTheme } = useTheme();

const session = useTerminalSession({
Expand Down Expand Up @@ -71,6 +74,24 @@ export const TerminalPane = forwardRef<TerminalPaneHandle, Props>(
<div
ref={containerRef}
className="h-full w-full"
data-path-drag-over={isPathDragOver ? "true" : undefined}
onDragOver={(e) => {
if (!Array.from(e.dataTransfer.types).includes(TERAX_PATH_MIME)) {
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsPathDragOver(true);
}}
onDragLeave={() => setIsPathDragOver(false)}
onDrop={(e) => {
const paths = readPathDragPayload(e.dataTransfer);
if (paths.length === 0) return;
e.preventDefault();
setIsPathDragOver(false);
session.write(shellQuoteAll(paths));
session.focus();
}}
style={{
visibility: visible ? "visible" : "hidden",
pointerEvents: visible ? "auto" : "none",
Expand Down
33 changes: 33 additions & 0 deletions src/modules/terminal/lib/shellQuote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { IS_WINDOWS } from "@/lib/platform";

/** Chars that are always safe unquoted in both cmd/PowerShell and POSIX shells. */
const SAFE_CHARS = /^[A-Za-z0-9_\-+.,/:@=%]+$/;

/**
* Shell-quote a single token (typically a filesystem path) so it pastes
* cleanly into an interactive shell.
*
* POSIX: wraps in single quotes; embedded `'` is handled with the
* classic `'\''` close-reopen trick.
*
* Windows: wraps in double quotes; embedded `"` is doubled, which is
* what both cmd.exe and PowerShell accept for literal quotes inside a
* double-quoted string.
*
* Tokens made exclusively of safe characters are returned as-is so
* common paths like `/home/user/file.txt` don't gain visual noise.
*/
export function shellQuote(token: string, windows: boolean = IS_WINDOWS): string {
if (!token) return windows ? '""' : "''";
if (SAFE_CHARS.test(token)) return token;

if (windows) {
return `"${token.replace(/"/g, '""')}"`;
}
return `'${token.replace(/'/g, `'\\''`)}'`;
}

/** Quote a list of paths and join them with spaces. */
export function shellQuoteAll(tokens: readonly string[], windows: boolean = IS_WINDOWS): string {
return tokens.map((t) => shellQuote(t, windows)).join(" ");
}
Loading