mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae14a61eec |
No files matched your search
@@ -27,13 +27,6 @@ pub fn set_text(text: &str, owner: usize) -> std::io::Result<()> {
|
||||
imp::set_text(text, owner)
|
||||
}
|
||||
|
||||
/// The clipboard's plain text, None when it holds none. macOS only (main thread);
|
||||
/// Linux reads it asynchronously through GTK in the app, Windows through the WebView.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn text() -> Option<String> {
|
||||
imp::text()
|
||||
}
|
||||
|
||||
/// Size of `DROPFILES`: `pFiles`, `pt` (two `LONG`s), `fNC`, `fWide`.
|
||||
const HEADER: usize = 20;
|
||||
|
||||
@@ -380,14 +373,6 @@ mod imp {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn text() -> Option<String> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
// SAFETY: the type is an AppKit constant, valid for the process' lifetime.
|
||||
NSPasteboard::generalPasteboard().stringForType(unsafe { NSPasteboardTypeString }).map(|s| s.to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_text(text: &str, _owner: usize) -> std::io::Result<()> {
|
||||
debug_assert!(MainThreadMarker::new().is_some(), "NSPasteboard access requires the main thread");
|
||||
autoreleasepool(|_| {
|
||||
|
||||
@@ -22,37 +22,6 @@ pub async fn clipboard_write_text(app: AppHandle, window: WebviewWindow, text: S
|
||||
with_clipboard(app, move || ide_clipboard::set_text(&text, owner).map_err(ide_core::IdeError::from)).await
|
||||
}
|
||||
|
||||
/// The clipboard's plain text, read natively as [`clipboard_write_text`] writes it: a Paste
|
||||
/// from a menu has no paste event, and WebKitGTK gives `navigator.clipboard.readText()` no
|
||||
/// text there (#3041). None on Windows, where the WebView's clipboard is used.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn clipboard_text(app: AppHandle) -> IdeResult<Option<String>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
app.run_on_main_thread(move || {
|
||||
let sender = std::cell::Cell::new(Some(tx));
|
||||
gtk::Clipboard::get(>k::gdk::SELECTION_CLIPBOARD).request_text(move |_, text| {
|
||||
if let Some(sender) = sender.take() {
|
||||
let _ = sender.send(text.map(|t| t.to_string()));
|
||||
}
|
||||
});
|
||||
})
|
||||
.map_err(|error| IdeError::internal(format!("Cannot dispatch clipboard access to the main thread: {error}")))?;
|
||||
Ok(rx.await.ok().flatten())
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
with_clipboard(app, || Ok(ide_clipboard::text())).await
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
let _ = app;
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute paths on the clipboard, empty when it holds no file list.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
|
||||
@@ -187,7 +187,6 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
fs::fs_read_text,
|
||||
fs::fs_open_with_system,
|
||||
clipboard::clipboard_write_text,
|
||||
clipboard::clipboard_text,
|
||||
clipboard::clipboard_file_list,
|
||||
clipboard::clipboard_has_files,
|
||||
clipboard::clipboard_image,
|
||||
|
||||
@@ -280,12 +280,6 @@ export const commands = {
|
||||
fsOpenWithSystem: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("fs_open_with_system", { path })),
|
||||
/** Writes text locally, without WebView user-activation requirements. */
|
||||
clipboardWriteText: (text: string) => typedError<null, IdeError>(__TAURI_INVOKE("clipboard_write_text", { text })),
|
||||
/**
|
||||
* The clipboard's plain text, read natively as [`clipboard_write_text`] writes it: a Paste
|
||||
* from a menu has no paste event, and WebKitGTK gives `navigator.clipboard.readText()` no
|
||||
* text there (#3041). None on Windows, where the WebView's clipboard is used.
|
||||
*/
|
||||
clipboardText: () => typedError<string | null, IdeError>(__TAURI_INVOKE("clipboard_text")),
|
||||
/** Absolute paths on the clipboard, empty when it holds no file list. */
|
||||
clipboardFileList: () => typedError<string[], IdeError>(__TAURI_INVOKE("clipboard_file_list")),
|
||||
/** Whether the clipboard holds files: cheap, for the Paste item's enabled state. */
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { java } from "@codemirror/lang-java";
|
||||
import { history, undo } from "@codemirror/commands";
|
||||
import { renderDocComments, renderedDocsExtension, renderedRanges } from "./renderedDocs";
|
||||
|
||||
let view: EditorView | null = null;
|
||||
beforeEach(() => void vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
view?.destroy();
|
||||
view = null;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const DOC = ["class Rooms {", " int n;", "", " /** Books a room. */", " void book() {}", "}"].join("\n");
|
||||
|
||||
function open(doc = DOC, extra: ReturnType<typeof history>[] = []) {
|
||||
view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc,
|
||||
extensions: [java(), renderedDocsExtension, renderDocComments.of(true), ...extra],
|
||||
}),
|
||||
parent: document.body,
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Types `text` at `at` the way a keystroke does. */
|
||||
const type = (v: EditorView, at: number, text: string) =>
|
||||
v.dispatch({ changes: { from: at, insert: text }, userEvent: "input.type" });
|
||||
|
||||
/** What a full rebuild of the same text renders, in a fresh editor state. */
|
||||
function fullRebuild(doc: string): Array<[number, number]> {
|
||||
return renderedRanges(
|
||||
EditorState.create({ doc, extensions: [java(), renderedDocsExtension, renderDocComments.of(true)] }),
|
||||
);
|
||||
}
|
||||
|
||||
const pause = () => vi.advanceTimersByTime(300);
|
||||
|
||||
it("moves rendered comments along with text typed in code, and rebuilds once typing pauses", () => {
|
||||
const v = open();
|
||||
const [before] = renderedRanges(v.state);
|
||||
// `/*` in code makes everything after it a comment: a full rebuild would drop the doc
|
||||
// comment at once. Typing only moves it along until the pause.
|
||||
type(v, v.state.doc.line(2).from + 2, "/*");
|
||||
expect(renderedRanges(v.state)).toEqual([[before![0] + 2, before![1] + 2]]);
|
||||
pause();
|
||||
expect(renderedRanges(v.state)).toEqual(fullRebuild(v.state.doc.toString()));
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders a pasted comment at once", () => {
|
||||
const v = open();
|
||||
v.dispatch({
|
||||
changes: { from: v.state.doc.line(5).to + 1, insert: " /** Pasted. */\n void paste() {}\n" },
|
||||
userEvent: "input.paste",
|
||||
});
|
||||
expect(renderedRanges(v.state)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("brings a deleted comment back rendered at once on undo", () => {
|
||||
const v = open(DOC, [history()]);
|
||||
const line = v.state.doc.line(4);
|
||||
v.dispatch({ changes: { from: line.from, to: line.to + 1 }, userEvent: "delete.selection" });
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
undo(v);
|
||||
expect(renderedRanges(v.state)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rebuilds at once for a keystroke on a comment's line", () => {
|
||||
const v = open();
|
||||
// Code after the closing marker: the comment no longer has its line to itself.
|
||||
type(v, v.state.doc.line(4).to, " int x;");
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still shows the source of a comment the caret lands in after fast typing", () => {
|
||||
const v = open();
|
||||
type(v, v.state.doc.line(2).from + 2, "xx");
|
||||
const at = v.state.doc.toString().indexOf("Books");
|
||||
v.dispatch({ selection: EditorSelection.cursor(at) });
|
||||
expect(renderedRanges(v.state)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("after the pause, matches a full rebuild of the same text, whatever was typed", () => {
|
||||
// A seeded generator, so a failure replays.
|
||||
let seed = 7;
|
||||
const next = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
|
||||
const lines = ["class A {"];
|
||||
for (let i = 0; i < 12; i++) lines.push(` /** Doc ${i}. */`, ` void m${i}() {}`, " /// line doc", "");
|
||||
lines.push("}");
|
||||
const v = open(lines.join("\n"));
|
||||
const keys = ["/", "*", "\n", "x", " ", "{", "}"];
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const at = Math.floor(next() * (v.state.doc.length + 1));
|
||||
if (next() < 0.3 && at > 0) v.dispatch({ changes: { from: at - 1, to: at }, userEvent: "delete.backward" });
|
||||
else type(v, at, keys[Math.floor(next() * keys.length)]!);
|
||||
if (next() < 0.05) pause();
|
||||
}
|
||||
pause();
|
||||
expect(renderedRanges(v.state)).toEqual(fullRebuild(v.state.doc.toString()));
|
||||
});
|
||||
@@ -25,13 +25,8 @@
|
||||
* Off, which is the default, the icon still shows over a comment, so one comment can be
|
||||
* read rendered, as in IDEA. That costs typing nothing: while the file renders no
|
||||
* comments and none is flipped, its comments are found only once typing pauses
|
||||
* (`deferredScanner`), the Markdown parser is not loaded, and the gutter has a column
|
||||
* (`passiveScanner`), the Markdown parser is not loaded, and the gutter has a column
|
||||
* only if the file has doc comments.
|
||||
*
|
||||
* On, typing costs about as little: a keystroke away from every comment's lines only
|
||||
* moves the rendered comments along with the text, and the file is scanned again once
|
||||
* typing pauses. Anything else that changes the text (paste, undo, a reload) rebuilds at
|
||||
* once, so the comments it brings in render at once.
|
||||
*/
|
||||
import {
|
||||
EditorState,
|
||||
@@ -41,7 +36,6 @@ import {
|
||||
RangeSet,
|
||||
StateEffect,
|
||||
StateField,
|
||||
type ChangeSet,
|
||||
type Extension,
|
||||
type Transaction,
|
||||
} from "@codemirror/state";
|
||||
@@ -375,55 +369,11 @@ type PlacedComment = DocComment & { line: number; end: number; rendered: boolean
|
||||
|
||||
interface DocsView {
|
||||
decorations: DecorationSet;
|
||||
/** Every doc comment, rendered or not, in document order. */
|
||||
/** Every doc comment, rendered or not. */
|
||||
comments: ReadonlyArray<PlacedComment>;
|
||||
/**
|
||||
* Moved along with keystrokes since the last scan, instead of found again: what the
|
||||
* parser made of them may differ, until `deferredScanner` rebuilds.
|
||||
*/
|
||||
stale: boolean;
|
||||
}
|
||||
|
||||
const INACTIVE: DocsView = { decorations: Decoration.none, comments: [], stale: false };
|
||||
|
||||
/** Asks for the scan a stale view is waiting for. */
|
||||
const rescanDocs = StateEffect.define<null>();
|
||||
|
||||
/** Comments moved along with a change, as an edit elsewhere moves them; their text is unchanged. */
|
||||
function mapComments(comments: ReadonlyArray<PlacedComment>, changes: ChangeSet): PlacedComment[] {
|
||||
return comments.map((c) => ({
|
||||
...c,
|
||||
from: changes.mapPos(c.from, 1),
|
||||
to: changes.mapPos(c.to, -1),
|
||||
line: changes.mapPos(c.line, 1),
|
||||
end: changes.mapPos(c.end, -1),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a change can skip the scan: a keystroke (typed or deleted characters) that
|
||||
* touches no comment's lines. Everything else (paste, undo, formatting, a reload) brings
|
||||
* comments in or out at once, so it rebuilds at once; a keystroke on a comment's line
|
||||
* moves its edges. A keystroke elsewhere can still change comments (`/*` makes the rest of
|
||||
* the file one), which the scan after the pause takes in.
|
||||
*/
|
||||
function typedOutsideComments(view: DocsView, tr: Transaction): boolean {
|
||||
if (!(tr.isUserEvent("input.type") || tr.isUserEvent("delete"))) return false;
|
||||
const { comments } = view;
|
||||
let touches = false;
|
||||
tr.changes.iterChangedRanges((fromA, toA) => {
|
||||
// The first comment that does not end before the change.
|
||||
let lo = 0;
|
||||
let hi = comments.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (comments[mid]!.end < fromA) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
if (lo < comments.length && comments[lo]!.line <= toA) touches = true;
|
||||
});
|
||||
return !touches;
|
||||
}
|
||||
const INACTIVE: DocsView = { decorations: Decoration.none, comments: [] };
|
||||
|
||||
/**
|
||||
* Rendered comments by their source, shared by every editor, least recently used first.
|
||||
@@ -492,7 +442,7 @@ function build(state: EditorState): DocsView {
|
||||
),
|
||||
);
|
||||
}
|
||||
return { decorations: Decoration.set(decorations), comments, stale: false };
|
||||
return { decorations: Decoration.set(decorations), comments };
|
||||
}
|
||||
|
||||
/** Start of the comment the mouse is over, or null. Cleared by any edit, which moves the text under it. */
|
||||
@@ -526,7 +476,7 @@ const setPassive = StateEffect.define<readonly PlacedComment[]>();
|
||||
/**
|
||||
* The doc comments of a file that renders none, for their icons. Rescanning the file on
|
||||
* each keystroke would make typing in a large file several times slower for a feature
|
||||
* that is off, so `deferredScanner` rescans once typing pauses; meanwhile the comments
|
||||
* that is off, so `passiveScanner` rescans once typing pauses; meanwhile the comments
|
||||
* found are mapped through the edits. Empty while the file renders comments: `docsField`
|
||||
* has them then.
|
||||
*/
|
||||
@@ -537,29 +487,32 @@ const passiveDocs = StateField.define<readonly PlacedComment[]>({
|
||||
// Switched off: the comments rendered a moment ago are known only to the last scan.
|
||||
if (active(tr.startState)) return placeComments(tr.state);
|
||||
for (const e of tr.effects) if (e.is(setPassive)) return e.value;
|
||||
return tr.docChanged ? mapComments(value, tr.changes) : value;
|
||||
if (!tr.docChanged) return value;
|
||||
const map = (pos: number, assoc: number) => tr.changes.mapPos(pos, assoc);
|
||||
return value.map((c) => ({
|
||||
...c,
|
||||
from: map(c.from, 1),
|
||||
to: map(c.to, -1),
|
||||
line: map(c.line, 1),
|
||||
end: map(c.end, -1),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Scans the file once edits and the parser have paused: a file that renders no comments,
|
||||
* for their icons; one that does, when keystrokes have only moved its comments along.
|
||||
*/
|
||||
const deferredScanner = ViewPlugin.fromClass(
|
||||
/** Scans a file that renders no comments once the edits and the parser have paused. */
|
||||
const passiveScanner = ViewPlugin.fromClass(
|
||||
class {
|
||||
timer: ReturnType<typeof setTimeout> | null = null;
|
||||
constructor(readonly view: EditorView) {}
|
||||
update(update: ViewUpdate) {
|
||||
if (!update.docChanged && syntaxTree(update.startState) === syntaxTree(update.state)) return;
|
||||
// Rendering and up to date: the view was just built, nothing is waiting.
|
||||
if (active(update.state) && !update.state.field(docsField).stale) return this.cancel();
|
||||
this.cancel();
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
const { state } = this.view;
|
||||
if (!active(state)) this.view.dispatch({ effects: setPassive.of(placeComments(state)) });
|
||||
else if (state.field(docsField).stale) this.view.dispatch({ effects: rescanDocs.of(null) });
|
||||
}, RESCAN_DELAY);
|
||||
if (active(update.state)) return this.cancel();
|
||||
if (update.docChanged || syntaxTree(update.startState) !== syntaxTree(update.state)) {
|
||||
this.cancel();
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
if (!active(this.view.state)) this.view.dispatch({ effects: setPassive.of(placeComments(this.view.state)) });
|
||||
}, PASSIVE_SCAN_DELAY);
|
||||
}
|
||||
}
|
||||
cancel() {
|
||||
if (this.timer !== null) clearTimeout(this.timer);
|
||||
@@ -571,8 +524,8 @@ const deferredScanner = ViewPlugin.fromClass(
|
||||
},
|
||||
);
|
||||
|
||||
/** How long edits and parsing must pause before the file is scanned again. */
|
||||
const RESCAN_DELAY = 300;
|
||||
/** How long edits and parsing must pause before a file that renders no comments is scanned. */
|
||||
const PASSIVE_SCAN_DELAY = 300;
|
||||
|
||||
/** Every doc comment of the file, as last found. */
|
||||
function placedComments(state: EditorState): ReadonlyArray<PlacedComment> {
|
||||
@@ -634,26 +587,13 @@ const docsField = StateField.define<DocsView>({
|
||||
create: build,
|
||||
update(value, tr) {
|
||||
if (tr.effects.some((e) => e.is(markdownLoaded))) renderCache.clear();
|
||||
if (
|
||||
const rebuild =
|
||||
tr.docChanged ||
|
||||
tr.reconfigured ||
|
||||
tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel) || e.is(rescanDocs))
|
||||
)
|
||||
return build(tr.state);
|
||||
if (tr.docChanged) {
|
||||
// A keystroke away from every comment moves them along; the scan waits for a pause.
|
||||
if (active(tr.state) && typedOutsideComments(value, tr))
|
||||
return {
|
||||
decorations: value.decorations.map(tr.changes),
|
||||
comments: mapComments(value.comments, tr.changes),
|
||||
stale: true,
|
||||
};
|
||||
return build(tr.state);
|
||||
}
|
||||
// The parser finishing more of the file can bring comments it had not reached: built
|
||||
// at once, so a file opens with the comments on screen rendered, unless keystrokes
|
||||
// already have the scan waiting for a pause.
|
||||
if (syntaxTree(tr.startState) !== syntaxTree(tr.state)) return value.stale ? value : build(tr.state);
|
||||
return value;
|
||||
tr.effects.some((e) => e.is(toggleRenderedView) || e.is(markdownLoaded) || e.is(relabel)) ||
|
||||
// The parser finishing more of the file can bring comments it had not reached.
|
||||
syntaxTree(tr.startState) !== syntaxTree(tr.state);
|
||||
return rebuild ? build(tr.state) : value;
|
||||
},
|
||||
provide: (f) => EditorView.decorations.from(f, (v) => v.decorations),
|
||||
});
|
||||
@@ -898,7 +838,7 @@ export const renderedDocsExtension: Extension = [
|
||||
flipped,
|
||||
docsField,
|
||||
passiveDocs,
|
||||
deferredScanner,
|
||||
passiveScanner,
|
||||
hovered,
|
||||
hoverTracker,
|
||||
showSourceAtCaret,
|
||||
|
||||
@@ -880,7 +880,7 @@ export class EditorController implements SlotHost {
|
||||
] as MenuItem[])
|
||||
: ([
|
||||
{ kind: "separator" },
|
||||
openInMenu(slot.model.path, view.state.doc.lineAt(view.state.selection.main.head).number - 1),
|
||||
openInMenu(slot.model.path),
|
||||
{ kind: "separator" },
|
||||
localHistoryMenu(slot.model.path),
|
||||
gitMenu(slot.model.path),
|
||||
@@ -1961,7 +1961,7 @@ export class EditorController implements SlotHost {
|
||||
}
|
||||
|
||||
/** Open In › for a file: Project View, Finder / Explorer / Files, Terminal. */
|
||||
function openInMenu(path: string, line?: number): MenuItem {
|
||||
function openInMenu(path: string): MenuItem {
|
||||
return {
|
||||
label: "Open In",
|
||||
submenu: [
|
||||
@@ -1971,7 +1971,7 @@ function openInMenu(path: string, line?: number): MenuItem {
|
||||
: []),
|
||||
{ kind: "action", action: "project.openInTerminal", arg: path, label: "Terminal" },
|
||||
{ kind: "separator" },
|
||||
...menuGroupItems("openIn", { paths: [path], line }),
|
||||
...menuGroupItems("openIn", { paths: [path] }),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Edit › Paste from History and Code › Unwrap/Remove: the parts that need popups or the backend. */
|
||||
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { EditorSelection } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { structure } from "@/editor/structure";
|
||||
@@ -103,7 +103,7 @@ export async function pasteAsPlainText(view: EditorView) {
|
||||
if (view.state.readOnly) return;
|
||||
let text: string;
|
||||
try {
|
||||
text = await readClipboardPlainText();
|
||||
text = (await navigator.clipboard?.readText()) ?? "";
|
||||
} catch {
|
||||
hint("Cannot read the clipboard");
|
||||
return;
|
||||
|
||||
@@ -11,7 +11,6 @@ import { quitApp } from "./closeGuard";
|
||||
import { guessOs } from "./host";
|
||||
import { showKeymapReference } from "./KeymapReference";
|
||||
import { checkForUpdates } from "./updates";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
|
||||
function errorText(e: unknown) {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
@@ -51,8 +50,8 @@ export function editCommand(command: "undo" | "redo" | "cut" | "copy" | "paste"
|
||||
if (focused instanceof Element && runUndoHandlers(command, focused)) return;
|
||||
}
|
||||
if (command === "selectAll" && selectReadingPane(document.activeElement)) return;
|
||||
if (command === "paste") {
|
||||
void readClipboardPlainText().then((text) => document.execCommand("insertText", false, text));
|
||||
if (command === "paste" && navigator.clipboard?.readText) {
|
||||
void navigator.clipboard.readText().then((text) => document.execCommand("insertText", false, text));
|
||||
return;
|
||||
}
|
||||
document.execCommand(command);
|
||||
|
||||
@@ -6,7 +6,6 @@ import { VENDORS, vendorPreset } from "./vendors";
|
||||
import { nodeTarget, objectOf, tableOf, type DbNode } from "./nodes";
|
||||
import { SCRIPT_LABELS, canDrop, canModifyTable, canRename, type ScriptKind } from "./objects";
|
||||
import { connectedTargets, openDialog, openSourceDialog, sourceById, useDatabase } from "./store";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
|
||||
const act = (
|
||||
action: string,
|
||||
@@ -49,7 +48,7 @@ function dataSourceItems(): MenuItem[] {
|
||||
async function sourceFromClipboard() {
|
||||
let text = "";
|
||||
try {
|
||||
text = await readClipboardPlainText();
|
||||
text = (await navigator.clipboard?.readText()) ?? "";
|
||||
} catch {
|
||||
// Unreadable clipboard: reported below like an unknown one.
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { useEffect, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { commands, isTauri, type RunConfig } from "@/platform/ipc";
|
||||
@@ -94,7 +94,7 @@ export function EnvDialog({
|
||||
};
|
||||
const paste = async () => {
|
||||
try {
|
||||
const text = await readClipboardPlainText();
|
||||
const text = await navigator.clipboard.readText();
|
||||
setRows((r) => pasteRows(r, text));
|
||||
} catch {
|
||||
/* clipboard unavailable */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { call, commands, isTauri, type RunConfig, type TomcatInstallation, type WebArtifact } from "@/platform/ipc";
|
||||
@@ -899,7 +899,7 @@ export function TomcatSettings({ config, onChange }: { config: RunConfig; onChan
|
||||
iconSize={13}
|
||||
title="Paste"
|
||||
onClick={() => {
|
||||
void readClipboardPlainText().then((text) => {
|
||||
void navigator.clipboard?.readText().then((text) => {
|
||||
if (!text.trim()) return;
|
||||
const idx = text.indexOf("=");
|
||||
const name = idx >= 0 ? text.slice(0, idx).trim() : text.trim();
|
||||
|
||||
@@ -16,7 +16,6 @@ import { findOutputLinks } from "./links";
|
||||
import { useRun } from "./store";
|
||||
import styles from "./StackTrace.module.css";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
|
||||
export interface StackTraceTab {
|
||||
id: string;
|
||||
@@ -84,7 +83,7 @@ export function closeStackTrace(id: string) {
|
||||
async function openDialog() {
|
||||
let clip = "";
|
||||
try {
|
||||
clip = await readClipboardPlainText();
|
||||
clip = (await navigator.clipboard?.readText()) ?? "";
|
||||
} catch {
|
||||
// clipboard unavailable or denied: start empty
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ import { usePostfixSettings } from "./postfix/store";
|
||||
import { showChooser, type ChooserItem } from "./popup/store";
|
||||
import { expandEmmetAtCaret } from "./emmet/editor";
|
||||
import { hasEmmet, loadEmmet } from "./emmet/model";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
|
||||
export interface TemplateDocInfo {
|
||||
path: string;
|
||||
@@ -83,7 +82,7 @@ function userName(): string {
|
||||
|
||||
async function readClipboard(): Promise<string> {
|
||||
try {
|
||||
return await readClipboardPlainText();
|
||||
return (await navigator.clipboard?.readText()) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -142,27 +142,6 @@ describe("ownTerminalInput", () => {
|
||||
expect(reached).toEqual(["keydown", "insertText"]);
|
||||
});
|
||||
|
||||
it("sends a shifted character an IME commits before its keydown", () => {
|
||||
// WeChat / Pinyin on macOS, Shift+/ for `?`: insertText arrives while only Shift is down.
|
||||
// Its keydown armed xterm's gate, which then drops the first press.
|
||||
const { textarea, sent, reached } = setup();
|
||||
keydown(textarea, { key: "Shift", keyCode: 16 });
|
||||
input(textarea, { inputType: "insertText", data: "?" });
|
||||
keydown(textarea, { key: "?", keyCode: 229 });
|
||||
keyup(textarea, 191);
|
||||
expect(sent).toEqual(["?"]);
|
||||
expect(reached).toEqual(["keydown"]);
|
||||
});
|
||||
|
||||
it("leaves shifted keys xterm sent itself to xterm", () => {
|
||||
const { textarea, sent, reached } = setup();
|
||||
keydown(textarea, { key: "Shift", keyCode: 16 });
|
||||
keydown(textarea, { key: "A", keyCode: 65 });
|
||||
input(textarea, { inputType: "insertText", data: "A" });
|
||||
expect(sent).toEqual([]);
|
||||
expect(reached).toEqual(["keydown", "keydown", "insertText"]);
|
||||
});
|
||||
|
||||
it("leaves input with no keydown behind it to xterm", () => {
|
||||
// Dictation, an on-screen keyboard or the emoji picker.
|
||||
const { textarea, sent, reached } = setup();
|
||||
@@ -280,25 +259,6 @@ describe("ownTerminalInput with xterm", () => {
|
||||
return sent;
|
||||
}
|
||||
|
||||
it("sends `?` on the first and the second press of Shift+/ with a direct-commit IME", async () => {
|
||||
const sent = await replay([
|
||||
["keydown", "Shift", 16],
|
||||
["input", "insertText", "?", "?"],
|
||||
["keydown", "?", 229],
|
||||
["keyup", "?", 191],
|
||||
["input", "insertText", "?", "??"],
|
||||
["keydown", "?", 229],
|
||||
["keyup", "?", 191],
|
||||
["keyup", "Shift", 16],
|
||||
["keydown", "Shift", 16],
|
||||
["keydown", "A", 65],
|
||||
["input", "insertText", "A", "??A"],
|
||||
["keyup", "A", 65],
|
||||
["keyup", "Shift", 16],
|
||||
]);
|
||||
expect(sent).toEqual(["?", "?", "A"]);
|
||||
});
|
||||
|
||||
it.each([true, false])("sends a retyped word and following plain keys once (keyup first: %s)", async (release) => {
|
||||
// Uppercase typing reaches both keypress and input; lowercase keydown is cancelled by xterm.
|
||||
const sent = await replay([
|
||||
|
||||
@@ -14,11 +14,7 @@
|
||||
* - a keyCode-229 keydown never reaches xterm, so its deferred textarea diff cannot run;
|
||||
* - while such a keydown is pending, an `insertText` is sent from the event's own `data`
|
||||
* (not from a value diff), then the textarea is cleared;
|
||||
* - composition input that arrives without a `compositionstart` is sent the same way;
|
||||
* - so is an `insertText` while only a modifier is down: an IME that commits a shifted
|
||||
* character directly (`?` from Shift+/ with WeChat or Pinyin) delivers it before the
|
||||
* character's keydown, and the Shift keydown already armed xterm's gate, so the first press
|
||||
* was dropped.
|
||||
* - composition input that arrives without a `compositionstart` is sent the same way.
|
||||
*
|
||||
* Everything else stays xterm's: real compositions (`compositionstart` … `compositionend`),
|
||||
* input events for keys xterm already sent, and input with no IME keydown behind it.
|
||||
@@ -32,8 +28,6 @@
|
||||
*/
|
||||
import { isImeKey } from "@/platform/ime";
|
||||
|
||||
const MODIFIERS = new Set(["Shift", "Control", "Alt", "Meta"]);
|
||||
|
||||
/** Commit-shaped input events that xterm's own input path ignores entirely. */
|
||||
const COMPOSITION_COMMITS = new Set(["insertFromComposition", "insertCompositionText"]);
|
||||
|
||||
@@ -68,9 +62,6 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
|
||||
let imeKey = false;
|
||||
/** The pending key belongs to an IME: its commit is ours to deliver. */
|
||||
let imeDown = false;
|
||||
/** Since the last keyup: a modifier went down, and any other key went down. */
|
||||
let modifierDown = false;
|
||||
let keyDown = false;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (composing) {
|
||||
@@ -78,8 +69,6 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
|
||||
if (e.keyCode === 0) e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
if (MODIFIERS.has(e.key)) modifierDown = true;
|
||||
else keyDown = true;
|
||||
imeKey = isImeKey(e);
|
||||
imeDown = imeKey;
|
||||
if (imeKey) {
|
||||
@@ -101,8 +90,6 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
|
||||
const onKeyUp = () => {
|
||||
imeKey = false;
|
||||
imeDown = false;
|
||||
modifierDown = false;
|
||||
keyDown = false;
|
||||
};
|
||||
|
||||
const onInput = (e: InputEvent) => {
|
||||
@@ -110,8 +97,7 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
|
||||
if (e.inputType === "insertText") {
|
||||
// Without an IME keydown behind it this is xterm's: it drops the event for a key it
|
||||
// already sent (Option/AltGr, plain typing) and delivers the rest (dictation, emoji).
|
||||
// With only a modifier down, no key of xterm's is behind it: xterm would drop it too.
|
||||
if (!imeDown && !(modifierDown && !keyDown)) return;
|
||||
if (!imeDown) return;
|
||||
} else if (!COMPOSITION_COMMITS.has(e.inputType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { commands } from "@/platform/ipc";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { installTerminalPaste, pasteTerminalClipboard, type TerminalPasteTarget } from "./paste";
|
||||
|
||||
const notify = vi.hoisted(() => vi.fn());
|
||||
@@ -526,28 +525,3 @@ it("recovers quoted local file paths when clipboard text permission is denied",
|
||||
expect(paste).toHaveBeenCalledExactlyOnceWith("'C:\\a b\\image.png'");
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
// WebKitGTK pastes nothing from a menu through execCommand or readText: native text first.
|
||||
{ os: "linux", native: "ls -la", expected: ["ls -la"], execCommand: 0 },
|
||||
// An image-only clipboard keeps the WebView paths.
|
||||
{ os: "linux", native: "", expected: [], execCommand: 1 },
|
||||
{ os: "macos", native: "ls -la", expected: ["ls -la"], execCommand: 1 },
|
||||
] as const)("menu paste on $os with native text '$native'", async ({ os, native, expected, execCommand }) => {
|
||||
usePlatform.setState({ os });
|
||||
vi.spyOn(commands, "clipboardText").mockResolvedValue({ status: "ok", data: native });
|
||||
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { readText: async () => "web" } });
|
||||
const exec = vi.fn(() => false);
|
||||
Object.defineProperty(document, "queryCommandSupported", { configurable: true, value: () => true });
|
||||
Object.defineProperty(document, "execCommand", { configurable: true, value: exec });
|
||||
const paste = vi.fn();
|
||||
try {
|
||||
await pasteTerminalClipboard(paste, () => localTarget());
|
||||
} finally {
|
||||
usePlatform.setState({ os: "macos" });
|
||||
Reflect.deleteProperty(document, "queryCommandSupported");
|
||||
Reflect.deleteProperty(document, "execCommand");
|
||||
}
|
||||
expect(exec).toHaveBeenCalledTimes(execCommand);
|
||||
expect(paste.mock.calls).toEqual(expected.map((text) => [text]));
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { pasteClipboardFiles, readClipboardPlainText, readClipboardText } from "@/platform/clipboard";
|
||||
import { pasteClipboardFiles, readClipboardText } from "@/platform/clipboard";
|
||||
import { call, commands, type TerminalId } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { usePlatform } from "@/platform/platformInfo";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
|
||||
export interface TerminalPasteTarget {
|
||||
@@ -121,15 +120,6 @@ export function installTerminalPaste(host: HTMLElement, paste: (text: string) =>
|
||||
export async function pasteTerminalClipboard(paste: (text: string) => void, getTarget?: Target) {
|
||||
const target = getTarget?.();
|
||||
try {
|
||||
// WebKitGTK pastes nothing from a menu through either WebView path; the native
|
||||
// clipboard has the text. An image-only clipboard still takes the paths below.
|
||||
if (target && usePlatform.getState().os === "linux") {
|
||||
const text = await readClipboardPlainText().catch(() => "");
|
||||
if (text) {
|
||||
if (target.current()) paste(text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Native paste preserves clipboard blobs where Clipboard.read() is unavailable.
|
||||
if (target && document.queryCommandSupported?.("paste") && document.execCommand("paste")) return;
|
||||
const images =
|
||||
@@ -140,7 +130,7 @@ export async function pasteTerminalClipboard(paste: (text: string) => void, getT
|
||||
)
|
||||
: null;
|
||||
const text = target?.remote
|
||||
? await readClipboardPlainText().catch(() => "")
|
||||
? ((await navigator.clipboard?.readText().catch(() => "")) ?? "")
|
||||
: await readClipboardText(undefined, target ? (paths) => pathsText(paths, target) : undefined);
|
||||
if (target && !target.current()) return;
|
||||
if (text) {
|
||||
|
||||
@@ -25,7 +25,6 @@ import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { Branch, ChangedFile, Commit } from "@/bindings";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { setContext } from "@/platform/context";
|
||||
import { maybeEditorService, useGitLogChecks, type EditorInput } from "@/workbench/contributions";
|
||||
import {
|
||||
EmptyState,
|
||||
@@ -2628,7 +2627,6 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
},
|
||||
[scope],
|
||||
);
|
||||
useEffect(() => () => setContext("vcsLogChangesFocus", false), []);
|
||||
|
||||
if (!commit)
|
||||
return (
|
||||
@@ -2669,13 +2667,7 @@ export function CommitDetails({ commits }: { commits: Commit[] }) {
|
||||
const details = (
|
||||
<SplitGroup orientation="vertical" id="git-log-details">
|
||||
<SplitPanel id="files" defaultSize="60%" minSize="60px">
|
||||
<div
|
||||
className={styles.filesPane}
|
||||
onFocusCapture={() => setContext("vcsLogChangesFocus", true)}
|
||||
onBlurCapture={(e) => {
|
||||
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setContext("vcsLogChangesFocus", false);
|
||||
}}
|
||||
>
|
||||
<div className={styles.filesPane}>
|
||||
<div className={styles.detailsBar} role="toolbar" aria-label={tx("Changed files")}>
|
||||
<IconButton
|
||||
icon="diff"
|
||||
|
||||
@@ -62,7 +62,6 @@ import { refreshNow } from "./store";
|
||||
import styles from "./MergeEditor.module.css";
|
||||
|
||||
import type { MergeData } from "./mergeKind";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
export { MERGE_KIND, mergeInput, type MergeData } from "./mergeKind";
|
||||
|
||||
const NOTHING_LEFT: MergeSummary = {
|
||||
@@ -498,7 +497,7 @@ export function MergeEditor({ input }: { input: EditorInput }) {
|
||||
const { from, to } = view.state.selection.main;
|
||||
if (id === "selectAll") view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
|
||||
else if (id === "paste") {
|
||||
const text = await readClipboardPlainText().catch(() => undefined);
|
||||
const text = await navigator.clipboard?.readText().catch(() => undefined);
|
||||
if (text !== undefined) view.dispatch({ ...view.state.replaceSelection(text), userEvent: "input.paste" });
|
||||
} else {
|
||||
await copyText(view.state.sliceDoc(from, to));
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fileWebUrl, remoteHosting } from "../model";
|
||||
|
||||
describe("remoteHosting", () => {
|
||||
it.each([
|
||||
["[email protected]:o/r.git", "GitHub", "github"],
|
||||
["https://github.example.com/o/r", "GitHub", "github"],
|
||||
["https://gitee.com/o/r.git", "Gitee", "github"],
|
||||
["[email protected]:o/r.git", "Bitbucket", "bitbucket"],
|
||||
["ssh://[email protected]:2222/group/sub/r.git", "GitLab", "gitlab"],
|
||||
["[email protected]:group/r.git", "git.example.com", "gitlab"],
|
||||
])("%s → %s", (url, name, kind) => {
|
||||
expect(remoteHosting(url)).toEqual({ name, kind });
|
||||
});
|
||||
|
||||
it("has none for a local remote", () => {
|
||||
expect(remoteHosting("/srv/git/r.git")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fileWebUrl", () => {
|
||||
it("opens a file at a line on GitHub", () => {
|
||||
expect(fileWebUrl("[email protected]:o/r.git", "main", "src/a b.ts", { line: 9 })).toBe(
|
||||
"https://github.com/o/r/blob/main/src/a%20b.ts#L10",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a branch's slashes and opens directories as trees", () => {
|
||||
expect(fileWebUrl("[email protected]:o/r.git", "feature/x", "src", { dir: true, line: 3 })).toBe(
|
||||
"https://github.com/o/r/tree/feature/x/src",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses GitLab's /-/ routes, including subgroups", () => {
|
||||
expect(fileWebUrl("ssh://[email protected]:2222/group/sub/r.git", "abc123", "docs/中文.md", { line: 0 })).toBe(
|
||||
"https://gitlab.example.com/group/sub/r/-/blob/abc123/docs/%E4%B8%AD%E6%96%87.md#L1",
|
||||
);
|
||||
expect(fileWebUrl("[email protected]:g/r.git", "main", "", { dir: true })).toBe("https://gitlab.com/g/r/-/tree/main");
|
||||
});
|
||||
|
||||
it("uses Bitbucket's /src/ routes", () => {
|
||||
expect(fileWebUrl("[email protected]:o/r.git", "main", "a.py", { line: 4 })).toBe(
|
||||
"https://bitbucket.org/o/r/src/main/a.py#lines-5",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -373,8 +373,7 @@ export function registerVcsActions(): () => void {
|
||||
icon: "diff",
|
||||
when: VCS,
|
||||
// ⌘D duplicates the line inside a text editor; Git › Show Diff still works there.
|
||||
// In the Git Log's changed files it shows the commit's diff instead.
|
||||
keyWhen: "!editorTextFocus && !vcsLogChangesFocus",
|
||||
keyWhen: "!editorTextFocus",
|
||||
run: async ({ arg, ctx }) => {
|
||||
if (ctx.shelfFocus && arg === undefined) return showShelfDiff();
|
||||
const line =
|
||||
|
||||
@@ -14,7 +14,6 @@ export { compareFilesData, displayPath } from "./compareData";
|
||||
import { openDiff, openVcsDiff, type DiffData } from "./diffInput";
|
||||
import { baseName } from "./model";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { readClipboardPlainText } from "@/platform/clipboard";
|
||||
|
||||
function argPath(arg: unknown): string | undefined {
|
||||
if (typeof arg === "string") return arg;
|
||||
@@ -286,7 +285,7 @@ export function registerCompareActions(): () => void {
|
||||
if (!path) return info("Open or select a file to compare with the clipboard");
|
||||
let clipboard: string;
|
||||
try {
|
||||
clipboard = await readClipboardPlainText();
|
||||
clipboard = (await navigator.clipboard?.readText()) ?? "";
|
||||
} catch {
|
||||
return info("The clipboard could not be read");
|
||||
}
|
||||
|
||||
@@ -109,8 +109,7 @@ function FrontWindow({ title, onClose, children }: { title: string; onClose: ()
|
||||
<div
|
||||
ref={body}
|
||||
className="vcsDiffDialogBody"
|
||||
// The dialog body is not a flex container: fill it so the editors reach the footer.
|
||||
style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}
|
||||
style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
registerTitleItem,
|
||||
registerToolWindow,
|
||||
} from "@/workbench/contributions";
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { lazyComponent } from "@/ui";
|
||||
import { registerVcsActions } from "./actions";
|
||||
@@ -50,7 +50,6 @@ import { closeLogTab, installGitLog, selectGitTab, useGitLog } from "./logStore"
|
||||
import { installVcsStore, onConflictsAppeared, onConflictsCleared, useVcs } from "./store";
|
||||
import { installRootSync } from "./rootSync";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { openOnRemoteActions, registerOpenOnRemote } from "./openOnRemote";
|
||||
|
||||
/** The Resolve/Continue/Abort buttons exist in the title bar only while there is something to resolve. */
|
||||
function trackMergeRebaseItem(): () => void {
|
||||
@@ -232,8 +231,6 @@ export function activate(): () => void {
|
||||
(signOff) => useCommit.setState({ signOff }),
|
||||
),
|
||||
registerVcsActions(),
|
||||
registerActions(openOnRemoteActions),
|
||||
registerOpenOnRemote(),
|
||||
registerHistoryActions(),
|
||||
registerLogPaneActions(),
|
||||
activateSettingsPages(),
|
||||
|
||||
@@ -152,7 +152,6 @@ export function registerLogPaneActions(): () => void {
|
||||
icon: "diff",
|
||||
when: LOG,
|
||||
hidden: true,
|
||||
keyWhen: "vcsLogChangesFocus",
|
||||
run: ({ arg }) => void showChangesDiff(changesArg(arg)),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -165,51 +165,7 @@ export function commitWebUrl(remoteUrl: string, sha: string): string | null {
|
||||
return `${base}/${/bitbucket\.org\//.test(base) ? "commits" : "commit"}/${sha}`;
|
||||
}
|
||||
|
||||
/** How a Git hosting service lays out its file pages. */
|
||||
export type HostingKind = "github" | "gitlab" | "bitbucket";
|
||||
|
||||
/**
|
||||
* The hosting service of a remote, by its host: GitHub (and GitHub Enterprise, Gitee, which share
|
||||
* its URLs), Bitbucket, and GitLab for gitlab.* and any other host, as self-managed GitLab is the
|
||||
* usual company server. Null for remotes with no web page (local paths).
|
||||
*/
|
||||
export function remoteHosting(url: string): { name: string; kind: HostingKind } | null {
|
||||
const base = remoteWebUrl(url);
|
||||
if (!base) return null;
|
||||
const host = new URL(base).hostname.toLowerCase();
|
||||
if (host === "github.com" || host.includes("github")) return { name: "GitHub", kind: "github" };
|
||||
if (host === "gitee.com") return { name: "Gitee", kind: "github" };
|
||||
if (host === "bitbucket.org") return { name: "Bitbucket", kind: "bitbucket" };
|
||||
if (host.includes("gitlab")) return { name: "GitLab", kind: "gitlab" };
|
||||
return { name: host, kind: "gitlab" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Page of a file or directory at `revision` on the remote's web UI, with the 0-based `line`
|
||||
* selected. `relative` is the `/`-separated path inside the repository ("" for its root).
|
||||
*/
|
||||
export function fileWebUrl(
|
||||
remoteUrl: string,
|
||||
revision: string,
|
||||
relative: string,
|
||||
opts: { dir?: boolean; line?: number } = {},
|
||||
): string | null {
|
||||
const base = remoteWebUrl(remoteUrl);
|
||||
const hosting = remoteHosting(remoteUrl);
|
||||
if (!base || !hosting) return null;
|
||||
const path = [...revision.split("/"), ...relative.split("/")].filter(Boolean).map(encodeURIComponent).join("/");
|
||||
const line = opts.line !== undefined && !opts.dir ? opts.line + 1 : null;
|
||||
switch (hosting.kind) {
|
||||
case "github":
|
||||
return `${base}/${opts.dir ? "tree" : "blob"}/${path}${line ? `#L${line}` : ""}`;
|
||||
case "gitlab":
|
||||
return `${base}/-/${opts.dir ? "tree" : "blob"}/${path}${line ? `#L${line}` : ""}`;
|
||||
case "bitbucket":
|
||||
return `${base}/src/${path}${line ? `#lines-${line}` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** "N file(s)", worded for the locale. */ /** "N file(s)", worded for the locale. */
|
||||
/** "N file(s)", worded for the locale. */
|
||||
export function fileCount(n: number): string {
|
||||
return n === 1 ? tx`${n} file` : tx`${n} files`;
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* Open In › GitHub / GitLab / Bitbucket: the file (at the caret line) or directory on the web UI
|
||||
* of its repository's remote, at the current branch's upstream (else the HEAD commit).
|
||||
*/
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import type { Action } from "@/platform/actions";
|
||||
import { notifications, registerMenuGroup } from "@/workbench/contributions";
|
||||
import { openUrl } from "./issueLinks";
|
||||
import { fileWebUrl, remoteHosting, rootOf } from "./model";
|
||||
import { useVcs } from "./store";
|
||||
|
||||
interface Target {
|
||||
path: string;
|
||||
dir?: boolean;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
/** Root → its remote's URL (null: none), read when the repositories change. */
|
||||
const remoteUrls = new Map<string, string | null>();
|
||||
|
||||
/** The remote a root's files open on: the selected repository's upstream remote, else `origin`. */
|
||||
function remoteName(root: string): string {
|
||||
const { repo, branch } = useVcs.getState();
|
||||
const upstream = root === repo ? branch?.upstream : null;
|
||||
return upstream ? upstream.slice(0, upstream.indexOf("/")) : "origin";
|
||||
}
|
||||
|
||||
async function readRemoteUrl(root: string): Promise<string | null> {
|
||||
const url = await call(commands.vcsConfigGet(root, `remote.${remoteName(root)}.url`)).catch(() => null);
|
||||
remoteUrls.set(root, url || null);
|
||||
return url || null;
|
||||
}
|
||||
|
||||
function revision(root: string): string | null {
|
||||
const { repo, branch, heads } = useVcs.getState();
|
||||
const upstream = root === repo ? branch?.upstream : null;
|
||||
if (upstream) return upstream.slice(upstream.indexOf("/") + 1);
|
||||
return heads[root]?.headCommit ?? null;
|
||||
}
|
||||
|
||||
function targetArg(arg: unknown): Target | null {
|
||||
if (typeof arg === "string") return { path: arg };
|
||||
if (arg && typeof arg === "object" && typeof (arg as Target).path === "string") return arg as Target;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function openOnRemote(target: Target) {
|
||||
const root = rootOf(target.path, useVcs.getState().roots);
|
||||
const url = root ? (remoteUrls.get(root) ?? (await readRemoteUrl(root))) : null;
|
||||
const rev = root ? revision(root) : null;
|
||||
const page =
|
||||
root && url && rev ? fileWebUrl(url, rev, target.path.slice(root.length).replace(/^\/+/, ""), target) : null;
|
||||
if (page) await openUrl(page);
|
||||
else notifications().notify({ group: "vcs", level: "warning", title: "This file has no page on a Git remote" });
|
||||
}
|
||||
|
||||
export const openOnRemoteActions: Action[] = [
|
||||
{
|
||||
id: "vcs.openOnRemote",
|
||||
title: "Open on Git Remote",
|
||||
description: "Opens the file or directory on the web page of its repository's remote.",
|
||||
category: "Git",
|
||||
icon: "external-link",
|
||||
when: "vcsAvailable",
|
||||
hidden: true,
|
||||
run: ({ arg }) => {
|
||||
const target = targetArg(arg);
|
||||
if (target) void openOnRemote(target);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Open In › <hosting>, for files and directories inside a repository with a web remote. */
|
||||
export function registerOpenOnRemote(): () => void {
|
||||
const refresh = (roots: readonly string[]) => {
|
||||
for (const root of [...remoteUrls.keys()]) if (!roots.includes(root)) remoteUrls.delete(root);
|
||||
for (const root of roots) void readRemoteUrl(root);
|
||||
};
|
||||
refresh(useVcs.getState().roots);
|
||||
const unsubscribe = useVcs.subscribe((s, prev) => {
|
||||
if (s.roots !== prev.roots || s.branch?.upstream !== prev.branch?.upstream) refresh(s.roots);
|
||||
});
|
||||
const unregister = registerMenuGroup({
|
||||
id: "vcs.openOnRemote",
|
||||
menus: ["openIn"],
|
||||
items: (_menu, { paths, dirs, line }) => {
|
||||
if (paths.length !== 1) return [];
|
||||
const path = paths[0]!;
|
||||
const root = rootOf(path, useVcs.getState().roots);
|
||||
const url = root ? remoteUrls.get(root) : null;
|
||||
const hosting = url ? remoteHosting(url) : null;
|
||||
if (!hosting) return [];
|
||||
const target: Target = { path, dir: dirs?.[0] ?? false, line };
|
||||
return [
|
||||
{
|
||||
id: "vcs.openOnRemote",
|
||||
// A service or host name, the same in every language.
|
||||
label: hosting.name,
|
||||
verbatim: true,
|
||||
icon: hosting.kind === "github" ? "github" : "external-link",
|
||||
run: () => void openOnRemote(target),
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unregister();
|
||||
remoteUrls.clear();
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readClipboardPlainText, writeClipboardText } from "@/platform/clipboard";
|
||||
import { writeClipboardText } from "@/platform/clipboard";
|
||||
import { commitRepo } from "./multiRoot";
|
||||
/** Create Patch… (from local changes or commits) and Apply Patch… (file or clipboard, with a preview). */
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -224,7 +224,7 @@ export async function showApplyPatch(from: "file" | "clipboard", path?: string)
|
||||
let source = "Clipboard";
|
||||
try {
|
||||
if (from === "clipboard") {
|
||||
text = await readClipboardPlainText();
|
||||
text = await navigator.clipboard.readText();
|
||||
} else {
|
||||
let file = path ?? null;
|
||||
if (!file) {
|
||||
|
||||
@@ -7,19 +7,6 @@ export async function writeClipboardText(text: string): Promise<void> {
|
||||
else await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* The clipboard's plain text, read natively where the backend can (macOS, Linux), else
|
||||
* through the WebView. A Paste from a menu has no paste event to read, and WebKitGTK's
|
||||
* `navigator.clipboard.readText()` gives no text there. Rejects when the WebView refuses.
|
||||
*/
|
||||
export async function readClipboardPlainText(): Promise<string> {
|
||||
if (isTauri()) {
|
||||
const text = await call(commands.clipboardText()).catch(() => null);
|
||||
if (text != null) return text;
|
||||
}
|
||||
return (await navigator.clipboard?.readText()) ?? "";
|
||||
}
|
||||
|
||||
type FormatFiles = (paths: string[]) => string;
|
||||
const rawPaths: FormatFiles = (paths) => paths.join("\n");
|
||||
|
||||
@@ -28,7 +15,9 @@ export async function readClipboardText(
|
||||
transfer?: DataTransfer | null,
|
||||
formatFiles: FormatFiles = rawPaths,
|
||||
): Promise<string> {
|
||||
const text = transfer ? transfer.getData("text/plain") : await readClipboardPlainText().catch(() => "");
|
||||
const text = transfer
|
||||
? transfer.getData("text/plain")
|
||||
: ((await navigator.clipboard?.readText().catch(() => "")) ?? "");
|
||||
if ((text && formatFiles === rawPaths) || (transfer && !hasClipboardFiles(transfer)) || !isTauri()) return text;
|
||||
let paths: string[];
|
||||
try {
|
||||
|
||||
@@ -50,7 +50,7 @@ const shared = [
|
||||
["code.quickDocumentation", "db.quickDoc", "debug.setValue", "vcs.changelist.edit"],
|
||||
["code.reformat", "project.reformatCode", "db.reformatValue"],
|
||||
["code.optimizeImports", "project.optimizeImports"],
|
||||
["code.deleteLine", "db.duplicateSource", "project.compareWith", "vcs.showDiff", "vcs.log.changes.showDiff"],
|
||||
["code.deleteLine", "db.duplicateSource", "project.compareWith", "vcs.showDiff"],
|
||||
["code.moveStatementDown", "find.field.recentSearches", "find.popup.recentSearches"],
|
||||
["code.foldAll", "project.collapseAll"],
|
||||
["refactor.rename", "project.rename", "db.rename", "vcs.changelist.edit", "vcs.log.editMessage", "vcs.shelf.rename"],
|
||||
|
||||
@@ -303,7 +303,6 @@ export const intellijMacos: Record<string, string[]> = {
|
||||
"vcs.rollback": ["Meta+Alt+Z"],
|
||||
"vcs.addToVcs": ["Meta+Alt+A"],
|
||||
"vcs.showDiff": ["Meta+D"],
|
||||
"vcs.log.changes.showDiff": ["Meta+D"],
|
||||
"vcs.showDiffInNewWindow": [],
|
||||
"vcs.nextChange": ["Ctrl+Alt+Shift+Down"],
|
||||
"vcs.prevChange": ["Ctrl+Alt+Shift+Up"],
|
||||
|
||||
@@ -297,7 +297,6 @@ export const intellijWindows: Record<string, string[]> = {
|
||||
"vcs.rollback": ["Ctrl+Alt+Z"],
|
||||
"vcs.addToVcs": ["Ctrl+Alt+A"],
|
||||
"vcs.showDiff": ["Ctrl+D"],
|
||||
"vcs.log.changes.showDiff": ["Ctrl+D"],
|
||||
"vcs.showDiffInNewWindow": [],
|
||||
"vcs.nextChange": ["Ctrl+Alt+Shift+Down"],
|
||||
"vcs.prevChange": ["Ctrl+Alt+Shift+Up"],
|
||||
|
||||
@@ -1456,8 +1456,5 @@
|
||||
"Dependencies": "Dependencies",
|
||||
"Configure the project interpreter and its packages, every interpreter the IDE knows, and Ruff.": "Configure the project interpreter and its packages, every interpreter the IDE knows, and Ruff.",
|
||||
"No credential store": "No credential store",
|
||||
"Values of agent and MCP server settings are kept in {path}, readable only by you.": "Values of agent and MCP server settings are kept in {path}, readable only by you.",
|
||||
"Open on Git Remote": "Open on Git Remote",
|
||||
"Opens the file or directory on the web page of its repository's remote.": "Opens the file or directory on the web page of its repository's remote.",
|
||||
"This file has no page on a Git remote": "This file has no page on a Git remote"
|
||||
"Values of agent and MCP server settings are kept in {path}, readable only by you.": "Values of agent and MCP server settings are kept in {path}, readable only by you."
|
||||
}
|
||||
@@ -7854,8 +7854,5 @@
|
||||
"All Interpreters": "所有解释器",
|
||||
"Configure the project interpreter and its packages, every interpreter the IDE knows, and Ruff.": "配置项目解释器及其软件包、IDE 已知的所有解释器,以及 Ruff。",
|
||||
"No credential store": "没有凭据存储",
|
||||
"Values of agent and MCP server settings are kept in {path}, readable only by you.": "智能体和 MCP 服务器设置中的值保存在 {path} 中,仅你本人可读取。",
|
||||
"Open on Git Remote": "在 Git 远程上打开",
|
||||
"Opens the file or directory on the web page of its repository's remote.": "在仓库远程的网页上打开该文件或目录。",
|
||||
"This file has no page on a Git remote": "该文件在 Git 远程上没有对应页面"
|
||||
"Values of agent and MCP server settings are kept in {path}, readable only by you.": "智能体和 MCP 服务器设置中的值保存在 {path} 中,仅你本人可读取。"
|
||||
}
|
||||
Reference in new issue
Block a user