feat: paste uri smartly (#1500)

This commit is contained in:
Myriad-Dreamin 2025-03-17 16:00:11 +08:00 committed by GitHub
parent 8a8cac096e
commit 7fb81604be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 214 additions and 36 deletions

View file

@ -64,25 +64,25 @@ export const typstPasteLinkEditKind = vscode.DocumentDropOrPasteEditKind.Empty.a
);
/** Kind for normal markdown links, i.e. include "path/to/file.typ" */
export const typstPasteUriEditKind = typstPasteLinkEditKind.append("uri");
export const typstUriEditKind = typstPasteLinkEditKind.append("uri");
export const typstPasteImageEditKind = typstPasteLinkEditKind.append("image");
export const typstImageEditKind = typstPasteLinkEditKind.append("image");
export const Mime = {
textUriList: "text/uri-list",
textPlain: "text/plain",
} as const;
export const mediaMimes = new Set([
export const typstSupportedMimes = new Set([
"image/avif",
"image/bmp",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
"video/mp4",
"video/ogg",
"audio/mpeg",
"audio/aac",
"audio/x-wav",
// "video/mp4",
// "video/ogg",
// "audio/mpeg",
// "audio/aac",
// "audio/x-wav",
]);

View file

@ -7,12 +7,12 @@ import { dirname, extname, basename, relative } from "path";
import { typstDocumentSelector } from "../util";
import {
Mime,
mediaMimes,
typstSupportedMimes,
PasteResourceKind,
pasteResourceKinds as pasteResourceKinds,
typstPasteImageEditKind,
typstImageEditKind,
typstPasteLinkEditKind,
typstPasteUriEditKind,
typstUriEditKind,
Schemes,
} from "./drop-paste.def";
import { IContext } from "../context";
@ -24,16 +24,17 @@ export function dragAndDropActivate(context: IContext) {
}
export function copyAndPasteActivate(context: IContext) {
const providedEditKinds = [
typstPasteLinkEditKind,
typstPasteUriEditKind,
typstPasteImageEditKind,
];
const providedEditKinds = [typstPasteLinkEditKind, typstUriEditKind, typstImageEditKind];
const sel = typstDocumentSelector;
context.subscriptions.push(
vscode.languages.registerDocumentPasteEditProvider(typstDocumentSelector, new PasteProvider(), {
vscode.languages.registerDocumentPasteEditProvider(sel, new PasteUriProvider(), {
providedPasteEditKinds: [typstPasteLinkEditKind],
pasteMimeTypes: PasteUriProvider.mimeTypes,
}),
vscode.languages.registerDocumentPasteEditProvider(sel, new PasteResourceProvider(), {
providedPasteEditKinds: providedEditKinds,
pasteMimeTypes: PasteProvider.mimeTypes,
pasteMimeTypes: PasteResourceProvider.mimeTypes,
}),
);
}
@ -54,12 +55,21 @@ interface ResolvedEdits {
}
class DropOrPasteContext<A extends DropPasteAction> {
title: string;
editKind = typstUriEditKind;
constructor(
private kind: A,
private context: vscode.DocumentPasteEditContext | undefined,
private document: vscode.TextDocument,
private token: vscode.CancellationToken,
) {}
) {
if (this.kind === DropPasteAction.Drop) {
this.title = "Drop (Typst)";
} else {
this.title = "Paste (Typst)";
}
}
private readonly _yieldTo = [
vscode.DocumentDropOrPasteEditKind.Text,
@ -79,7 +89,7 @@ class DropOrPasteContext<A extends DropPasteAction> {
dropEdit.yieldTo = [...this._yieldTo, ...edit.yieldTo];
return dropEdit as EditClass<A>;
} else {
const pasteEdit = new vscode.DocumentPasteEdit(edit.snippet, "Paste", typstPasteUriEditKind);
const pasteEdit = new vscode.DocumentPasteEdit(edit.snippet, this.title, this.editKind);
pasteEdit.additionalEdit = edit.additionalEdits;
pasteEdit.yieldTo = [...this._yieldTo, ...edit.yieldTo];
return pasteEdit as EditClass<A>;
@ -95,6 +105,7 @@ class DropOrPasteContext<A extends DropPasteAction> {
if (mediaFiles) {
const edit = await this.handleMediaFiles(ranges, mediaFiles);
if (edit) {
this.editKind = typstImageEditKind;
this.resolved.push(edit);
return this.wrapRangeAsLinkContent();
}
@ -102,7 +113,7 @@ class DropOrPasteContext<A extends DropPasteAction> {
const uriList = await this.takeUriList(dataTransfer);
if (uriList) {
const edit = await this.editByUriList(ranges, uriList);
const edit = await this.editByUriList(ranges, uriList, false);
if (edit) {
this.resolved.push(edit);
return this.wrapRangeAsLinkContent();
@ -113,6 +124,32 @@ class DropOrPasteContext<A extends DropPasteAction> {
return false;
}
async pasteUri(ranges: readonly vscode.Range[], dataTransfer: vscode.DataTransfer) {
this.editKind = typstUriEditKind;
this.title = "Paste Link (Typst)";
const item = dataTransfer.get(Mime.textPlain);
const text = await item?.asString();
if (this.token.isCancellationRequested || !text) {
return;
}
// TODO: If the user has explicitly requested to paste as a typst link,
// try to paste even if we don't have a valid uri
const uriText = findValidUriInText(text);
if (!uriText) {
return;
}
const uriList = UriList.from(uriText);
const edit = await this.editByUriList(ranges, uriList, false);
if (edit) {
this.resolved.push(edit);
return this.wrapRangeAsLinkContent();
}
return false;
}
wrapRangeAsLinkContent(): boolean {
// todo: link content support
// if (
@ -134,7 +171,7 @@ class DropOrPasteContext<A extends DropPasteAction> {
const fileEntries = coalesce(
await Promise.all(
Array.from(dataTransfer, async ([mime, item]): Promise<MediaFileEntry | undefined> => {
if (!mediaMimes.has(mime)) {
if (!typstSupportedMimes.has(mime)) {
return;
}
@ -183,7 +220,7 @@ class DropOrPasteContext<A extends DropPasteAction> {
if (
uriList.entries.length === 1 &&
[Schemes.http, Schemes.https].includes(uriList.entries[0].uri.scheme as Schemes) &&
!this.context?.only?.contains(typstPasteUriEditKind)
!this.context?.only?.contains(typstUriEditKind)
) {
const text = await dataTransfer.get(Mime.textPlain)?.asString();
if (this.token.isCancellationRequested) {
@ -203,7 +240,7 @@ class DropOrPasteContext<A extends DropPasteAction> {
mediaFiles.map((entry) => ({ uri: entry.uri, str: entry.uri.toString() })),
);
return this.editByUriList(ranges, mediaUriList, (additionalEdits) => {
return this.editByUriList(ranges, mediaUriList, true, (additionalEdits) => {
for (const entry of mediaFiles) {
if (entry.newFile) {
additionalEdits.createFile(entry.uri, {
@ -218,6 +255,7 @@ class DropOrPasteContext<A extends DropPasteAction> {
async editByUriList(
ranges: readonly vscode.Range[],
uriList: UriList,
isMedia: boolean,
createAdditionalEdits?: (additionalEdits: vscode.WorkspaceEdit) => void,
): Promise<ResolvedEdits | undefined> {
if (uriList.entries.length !== 1) {
@ -239,12 +277,14 @@ class DropOrPasteContext<A extends DropPasteAction> {
const additionalImports = new Set<string>();
let resolved = true;
for (const range of orderedRanges) {
const snippetEdit = await this.createUriListSnippet(uriList, range, {
placeholderText: range.isEmpty ? undefined : this.document.getText(range),
isMedia,
placeholderStartIndex: allRangesAreEmpty ? 1 : placeHolderStartIndex,
});
if (!snippetEdit) {
resolved = false;
continue;
}
@ -263,6 +303,10 @@ class DropOrPasteContext<A extends DropPasteAction> {
}
}
if (!resolved) {
return;
}
const imports = Array.from(additionalImports).sort();
if (imports.length > 0) {
additionalEdits.insert(this.document.uri, new vscode.Position(0, 0), imports.join(""));
@ -280,16 +324,77 @@ class DropOrPasteContext<A extends DropPasteAction> {
async createUriListSnippet(
uriList: UriList,
range: vscode.Range,
_exts: { placeholderText: string | undefined; placeholderStartIndex: number },
exts: { isMedia: boolean; placeholderStartIndex: number },
) {
if (uriList.entries.length !== 1) {
vscode.window.showErrorMessage("Only one URI can be pasted at a time.");
return;
}
const entry = uriList.entries[0];
if (exts.isMedia || entry.uri.scheme === "file" || entry.uri.scheme === "untitled") {
return this.createLocalUriListSnippet(entry.uri, range, exts);
} else {
return this.createRemoteUriListSnippet(entry.uri, range, exts);
}
}
async createRemoteUriListSnippet(
dragFileUri: vscode.Uri,
range: vscode.Range,
_exts: { placeholderStartIndex: number },
) {
// todo: check valid plain url, because some url may contain invalid characters for http markup.
if (range.isEmpty) {
return;
}
const res = await vscode.commands.executeCommand<
[{ mode: "math" | "markup" | "code" | "comment" | "string" | "raw" }]
>("tinymist.interactCodeContext", {
textDocument: {
uri: this.document.uri.toString(),
},
query: [
{
kind: "modeAt",
position: {
line: range.start.line,
character: range.start.character,
},
},
],
});
const linkText = dragFileUri.toString();
const wrappedText = this.document.getText(range);
let text = "";
switch (res?.[0]?.mode || undefined) {
case "markup":
text = `#link("${escapeStr(linkText)}")[${wrappedText}]`;
break;
case "math":
text = `#link("${escapeStr(linkText)}", $${wrappedText}$)`;
break;
case "code":
text = `link("${escapeStr(linkText)}", {${wrappedText}})`;
break;
case "string":
case "comment":
case "raw":
return undefined;
}
return [text, []] as const;
}
async createLocalUriListSnippet(
dragFileUri: vscode.Uri,
range: vscode.Range,
_exts: { placeholderStartIndex: number },
) {
const dropFileUri = this.document.uri;
// todo: multiple files
const dragFileUri = uriList.entries[0].uri;
let dragFilePath = "";
const workspaceFolder = vscode.workspace.getWorkspaceFolder(dragFileUri);
@ -301,8 +406,8 @@ class DropOrPasteContext<A extends DropPasteAction> {
dragFilePath = relative(dirname(dropFileUri.fsPath), dragFileUri.fsPath);
}
const barPath = dragFilePath.replace(/\\/g, "/");
const strPath = `"${barPath}"`;
const barStrPath = escapeStr(dragFilePath.replace(/\\/g, "/"));
const strPath = `"${barStrPath}"`;
let codeSnippet = strPath;
const resourceKind: PasteResourceKind | undefined =
pasteResourceKinds[extname(dragFileUri.fsPath)];
@ -374,7 +479,7 @@ class DropOrPasteContext<A extends DropPasteAction> {
});
let text = codeSnippet;
switch (res?.[0]?.mode) {
switch (res?.[0]?.mode || undefined) {
case "math":
case "markup":
text = `#${codeSnippet}`;
@ -382,11 +487,13 @@ class DropOrPasteContext<A extends DropPasteAction> {
case "code":
text = codeSnippet;
break;
case "string":
text = barStrPath;
break;
case "comment":
case "raw":
case "string":
text = barPath;
break;
case undefined:
return undefined;
}
const additionalImports = [];
@ -430,8 +537,8 @@ export class DropProvider implements vscode.DocumentDropEditProvider {
}
}
export class PasteProvider implements vscode.DocumentPasteEditProvider {
public static readonly mimeTypes = [Mime.textUriList, "files", ...mediaMimes];
export class PasteResourceProvider implements vscode.DocumentPasteEditProvider {
public static readonly mimeTypes = [Mime.textUriList, "files", ...typstSupportedMimes];
public async provideDocumentPasteEdits(
document: vscode.TextDocument,
@ -452,6 +559,27 @@ export class PasteProvider implements vscode.DocumentPasteEditProvider {
}
}
export class PasteUriProvider implements vscode.DocumentPasteEditProvider {
public static readonly mimeTypes = [Mime.textPlain];
public async provideDocumentPasteEdits(
document: vscode.TextDocument,
ranges: readonly vscode.Range[],
dataTransfer: vscode.DataTransfer,
context: vscode.DocumentPasteEditContext,
token: vscode.CancellationToken,
): Promise<vscode.DocumentPasteEdit[] | undefined> {
const ctx = new PasteContext(DropPasteAction.Paste, context, document, token);
const transferred = await ctx.pasteUri(ranges, dataTransfer);
if (!transferred || token.isCancellationRequested) {
return;
}
return ctx.finalize();
}
}
type OverwriteBehavior = "overwrite" | "nameIncrementally";
export interface CopyFileConfiguration {
@ -598,6 +726,56 @@ export class UriList {
) {}
}
const externalUriSchemes: ReadonlySet<string> = new Set([
Schemes.http,
Schemes.https,
Schemes.mailto,
Schemes.file,
]);
export function findValidUriInText(text: string): string | undefined {
const trimmedUrlList = text.trim();
if (
!/^\S+$/.test(trimmedUrlList) || // Uri must consist of a single sequence of characters without spaces
!trimmedUrlList.includes(":") // And it must have colon somewhere for the scheme. We will verify the schema again later
) {
return;
}
let uri: vscode.Uri;
try {
uri = vscode.Uri.parse(trimmedUrlList);
} catch {
// Could not parse
return;
}
// `Uri.parse` is lenient and will return a `file:` uri even for non-uri text such as `abc`
// Make sure that the resolved scheme starts the original text
if (!trimmedUrlList.toLowerCase().startsWith(uri.scheme.toLowerCase() + ":")) {
return;
}
// Only enable for an allow list of schemes. Otherwise this can be accidentally activated for non-uri text
// such as `c:\abc` or `value:foo`
if (!externalUriSchemes.has(uri.scheme.toLowerCase())) {
return;
}
// Some part of the uri must not be empty
// This disables the feature for text such as `http:`
if (!uri.authority && uri.path.length < 2 && !uri.query && !uri.fragment) {
return;
}
return trimmedUrlList;
}
function coalesce<T>(array: ReadonlyArray<T | undefined | null>): T[] {
return <T[]>array.filter((e) => !!e);
}
function escapeStr(str: string): string {
return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}