mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
Merge remote-tracking branch 'origin/main' into fix/flaky-lsp-tests
# Conflicts: # crates/ide-jsonrpc/src/lib.rs
This commit is contained in:
@@ -7,7 +7,8 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
# A newer push supersedes the run in progress, on main too.
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -29,7 +30,10 @@ jobs:
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { components: "clippy, rustfmt" }
|
||||
# Only main saves the cache (saving takes minutes on Windows); PRs restore main's.
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
- uses: taiki-e/install-action@nextest
|
||||
- name: Linux deps
|
||||
if: runner.os == 'Linux'
|
||||
@@ -45,9 +49,10 @@ jobs:
|
||||
# Test fixtures write LF files; keep Git from converting them on checkout.
|
||||
- run: git config --global core.autocrlf false
|
||||
- run: cargo nextest run --workspace --no-fail-fast
|
||||
# nextest ran export_bindings, which rewrites src/bindings.ts.
|
||||
- name: Bindings are up to date
|
||||
if: runner.os == 'Linux'
|
||||
run: pnpm gen:bindings && git diff --exit-code src/bindings.ts
|
||||
run: git diff --exit-code src/bindings.ts
|
||||
|
||||
frontend:
|
||||
name: Frontend
|
||||
@@ -63,8 +68,22 @@ jobs:
|
||||
- run: pnpm lint
|
||||
- run: pnpm test
|
||||
- run: pnpm build
|
||||
- run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
e2e:
|
||||
name: E2E
|
||||
runs-on: ubuntu-24.04
|
||||
# Browsers and their system libraries are preinstalled; keep the tag in step with @playwright/test.
|
||||
container: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm e2e
|
||||
env:
|
||||
HOME: /root
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
|
||||
Generated
+30
-30
@@ -3962,7 +3962,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -3980,7 +3980,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -3994,7 +3994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4009,11 +4009,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"serde",
|
||||
@@ -4025,7 +4025,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4039,7 +4039,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4056,7 +4056,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"futures",
|
||||
@@ -4076,7 +4076,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4090,7 +4090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4104,7 +4104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4122,7 +4122,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4144,7 +4144,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4160,7 +4160,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4213,7 +4213,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"globset",
|
||||
@@ -4236,7 +4236,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4254,7 +4254,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4267,7 +4267,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"ide-core",
|
||||
@@ -4287,7 +4287,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4306,7 +4306,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4326,7 +4326,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4339,7 +4339,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4354,7 +4354,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4367,7 +4367,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4379,7 +4379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4399,7 +4399,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"gix",
|
||||
@@ -4416,7 +4416,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4437,7 +4437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4453,7 +4453,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -5878,7 +5878,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.17"
|
||||
version = "0.1.18"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -191,6 +191,8 @@ struct Task {
|
||||
dirty: Mutex<BTreeSet<usize>>,
|
||||
flush_scheduled: AtomicBool,
|
||||
stderr: Mutex<VecDeque<String>>,
|
||||
/// Set by `delete`; held while saving, so a late save can't bring the file back.
|
||||
deleted: Mutex<bool>,
|
||||
}
|
||||
|
||||
impl AgentManager {
|
||||
@@ -519,6 +521,7 @@ impl AgentManager {
|
||||
blocking(move || worktree::remove(&root, &cwd)).await?;
|
||||
}
|
||||
self.tasks.write().remove(id);
|
||||
*task.deleted.lock() = true;
|
||||
let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{id}.json")));
|
||||
self.shared.emit(AgentEvent::Removed { id: id.to_owned() });
|
||||
Ok(())
|
||||
@@ -568,6 +571,7 @@ impl Task {
|
||||
dirty: Mutex::new(BTreeSet::new()),
|
||||
flush_scheduled: AtomicBool::new(false),
|
||||
stderr: Mutex::new(VecDeque::new()),
|
||||
deleted: Mutex::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -604,6 +608,10 @@ impl Task {
|
||||
})
|
||||
};
|
||||
let path = self.shared.paths.store_dir.join(format!("{}.json", self.id));
|
||||
let deleted = self.deleted.lock();
|
||||
if *deleted {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = bytes.map_err(|e| e.to_string()).and_then(|b| write_file(&path, &b)) {
|
||||
tracing::warn!("saving agent task {}: {e}", self.id);
|
||||
}
|
||||
|
||||
@@ -222,13 +222,6 @@ impl Handler for NoopHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/// Work for the in-order notification thread.
|
||||
enum Inbound {
|
||||
Notification(String, Value),
|
||||
/// A request may start: the notifications before it are handled.
|
||||
Release(oneshot::Sender<()>),
|
||||
}
|
||||
|
||||
type Pending = HashMap<i64, oneshot::Sender<Result<Value, RpcError>>>;
|
||||
|
||||
struct Inner {
|
||||
@@ -508,27 +501,30 @@ impl Peer {
|
||||
});
|
||||
}
|
||||
|
||||
// Notifications are delivered in order on a dedicated thread.
|
||||
let (notif_tx, notif_rx) = std::sync::mpsc::channel::<Inbound>();
|
||||
// Notifications are delivered in order on a dedicated thread. Requests start from the same
|
||||
// queue, so a request handler sees every notification that arrived before it.
|
||||
let (notif_tx, notif_rx) = std::sync::mpsc::channel::<Incoming>();
|
||||
{
|
||||
let handler = handler.clone();
|
||||
let inner = inner.clone();
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
std::thread::Builder::new()
|
||||
.name("jsonrpc-notify".into())
|
||||
.spawn(move || {
|
||||
while let Ok(inbound) = notif_rx.recv() {
|
||||
let (method, params) = match inbound {
|
||||
Inbound::Notification(method, params) => (method, params),
|
||||
Inbound::Release(start) => {
|
||||
let _ = start.send(());
|
||||
continue;
|
||||
while let Ok(msg) = notif_rx.recv() {
|
||||
match msg {
|
||||
Incoming::Notification(method, params) => {
|
||||
let h = handler.clone();
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
h.on_notification(&method, params)
|
||||
}));
|
||||
if result.is_err() {
|
||||
tracing::error!("jsonrpc notification handler panicked on {method}");
|
||||
}
|
||||
}
|
||||
Incoming::Request(id, method, params) => {
|
||||
runtime.spawn(handle_request(inner.clone(), handler.clone(), id, method, params));
|
||||
}
|
||||
};
|
||||
let h = handler.clone();
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
h.on_notification(&method, params)
|
||||
}));
|
||||
if result.is_err() {
|
||||
tracing::error!("jsonrpc notification handler panicked on {method}");
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -560,7 +556,7 @@ impl Peer {
|
||||
match msg {
|
||||
Ok(Some(body)) => {
|
||||
seen_message.store(true, Ordering::Relaxed);
|
||||
dispatch(&inner, &handler, ¬if_tx, &body);
|
||||
dispatch(&inner, ¬if_tx, &body);
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
@@ -697,7 +693,28 @@ fn parse_id(id: &Value) -> Option<i64> {
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(inner: &Arc<Inner>, handler: &Arc<dyn Handler>, notif_tx: &std::sync::mpsc::Sender<Inbound>, body: &[u8]) {
|
||||
enum Incoming {
|
||||
Notification(String, Value),
|
||||
Request(Value, String, Value),
|
||||
}
|
||||
|
||||
async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value, method: String, params: Value) {
|
||||
let m = method.clone();
|
||||
let result = tokio::task::spawn_blocking(move || handler.on_request(&m, params)).await;
|
||||
let reply = match result {
|
||||
Ok(Ok(result)) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
|
||||
Ok(Err((code, message))) => {
|
||||
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
|
||||
}
|
||||
Err(_) => json!({
|
||||
"jsonrpc": "2.0", "id": id,
|
||||
"error": { "code": codes::INTERNAL_ERROR, "message": format!("handler for {method} panicked") }
|
||||
}),
|
||||
};
|
||||
let _ = inner.send_value(&reply);
|
||||
}
|
||||
|
||||
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
|
||||
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
|
||||
let msg: Value = match serde_json::from_slice(body) {
|
||||
Ok(v) => v,
|
||||
@@ -716,30 +733,10 @@ fn dispatch(inner: &Arc<Inner>, handler: &Arc<dyn Handler>, notif_tx: &std::sync
|
||||
let params = obj.remove("params").unwrap_or(Value::Null);
|
||||
match id {
|
||||
Some(id) if !id.is_null() => {
|
||||
let handler = handler.clone();
|
||||
let inner = inner.clone();
|
||||
let (start, started) = oneshot::channel();
|
||||
let _ = notif_tx.send(Inbound::Release(start));
|
||||
tokio::spawn(async move {
|
||||
// E.g. a `didOpen` before a request must be applied when the request is answered.
|
||||
let _ = started.await;
|
||||
let m = method.clone();
|
||||
let result = tokio::task::spawn_blocking(move || handler.on_request(&m, params)).await;
|
||||
let reply = match result {
|
||||
Ok(Ok(result)) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
|
||||
Ok(Err((code, message))) => {
|
||||
json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } })
|
||||
}
|
||||
Err(_) => json!({
|
||||
"jsonrpc": "2.0", "id": id,
|
||||
"error": { "code": codes::INTERNAL_ERROR, "message": format!("handler for {method} panicked") }
|
||||
}),
|
||||
};
|
||||
let _ = inner.send_value(&reply);
|
||||
});
|
||||
let _ = notif_tx.send(Incoming::Request(id, method, params));
|
||||
}
|
||||
_ => {
|
||||
let _ = notif_tx.send(Inbound::Notification(method, params));
|
||||
let _ = notif_tx.send(Incoming::Notification(method, params));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "openidea",
|
||||
"private": true,
|
||||
"description": "An open-source IDE inspired by IntelliJ IDEA",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.18",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "OpenIDEA",
|
||||
"version": "0.1.17",
|
||||
"version": "0.1.18",
|
||||
"identifier": "ai.l0ng.openidea",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
goToPreviousChunk,
|
||||
rejectChunk,
|
||||
unifiedMergeView,
|
||||
type Chunk,
|
||||
Chunk,
|
||||
} from "@codemirror/merge";
|
||||
import { attachDocument, embeddedEditorExtensions, type DocumentAttachment } from "@/editor/api";
|
||||
import { setContext } from "@/platform/context";
|
||||
@@ -82,6 +82,23 @@ export function chunkLines(chunk: Chunk, a: Text, b: Text) {
|
||||
return { fromA: fromA!, toA: toA!, fromB: fromB!, toB: toB! };
|
||||
}
|
||||
|
||||
/** The 0-based line of `to` matching `line` of `from`, given the chunks between them. */
|
||||
export function mapLine(chunks: readonly Chunk[], line: number, from: Text, to: Text): number {
|
||||
const span = (start: number, end: number, doc: Text) => {
|
||||
const first = doc.lineAt(Math.min(start, doc.length)).number - 1;
|
||||
return [first, end > start ? doc.lineAt(Math.min(end - 1, doc.length)).number : first] as const;
|
||||
};
|
||||
let shift = 0;
|
||||
for (const c of chunks) {
|
||||
const [fromA, toA] = span(c.fromA, c.toA, from);
|
||||
if (line < fromA) break;
|
||||
const [fromB, toB] = span(c.fromB, c.toB, to);
|
||||
if (line < toA) return Math.min(fromB + (line - fromA), Math.max(fromB, toB - 1));
|
||||
shift = toB - toA;
|
||||
}
|
||||
return Math.max(0, Math.min(line + shift, to.lines - 1));
|
||||
}
|
||||
|
||||
/** Joins the open document of a file-backed side (working file, Compare Files); null for revisions / texts. */
|
||||
async function attachSide(side: DiffSide, path: string): Promise<DocumentAttachment | null> {
|
||||
const file = sideFile(side, path);
|
||||
@@ -372,21 +389,49 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
[updateChunkInfo, data],
|
||||
);
|
||||
|
||||
// A file-backed side is the source; for a diff of revisions (Git Log, Compare) the
|
||||
// working copy of the path is, while it exists. Probed once per diff.
|
||||
const fileSource = sideFile(left, path) ?? sideFile(right, path);
|
||||
const [onDisk, setOnDisk] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (fileSource) return;
|
||||
let alive = true;
|
||||
void call(commands.fsProbe(path)).then(
|
||||
() => alive && setOnDisk(path),
|
||||
() => alive && setOnDisk(null),
|
||||
);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [path, fileSource]);
|
||||
const worktreeSource = onDisk === path ? path : null;
|
||||
const source = fileSource ?? worktreeSource;
|
||||
|
||||
const jumpToSource = useCallback(() => {
|
||||
const views = viewsRef.current;
|
||||
const fileA = sideFile(left, path);
|
||||
const fileB = sideFile(right, path);
|
||||
const useA = focusedSide.current === "a" && !!views?.a && !!fileA;
|
||||
const v = useA ? views!.a! : views?.b;
|
||||
const file = useA ? fileA : (fileB ?? fileA);
|
||||
const onA = focusedSide.current === "a" && !!views?.a;
|
||||
const v = onA ? views!.a! : views?.b;
|
||||
const own = onA ? fileA : fileB;
|
||||
const file = own ?? fileB ?? fileA ?? worktreeSource;
|
||||
if (!file) return;
|
||||
const head = v ? v.state.selection.main.head : 0;
|
||||
const line = v ? v.state.doc.lineAt(head) : null;
|
||||
void editorService().openFile(file, {
|
||||
selection: { line: line ? line.number - 1 : 0, col: line ? head - line.from : 0 },
|
||||
focus: true,
|
||||
const open = (line: number, col: number) =>
|
||||
void editorService().openFile(file, { selection: { line, col }, focus: true });
|
||||
if (!v) return open(0, 0);
|
||||
const head = v.state.selection.main.head;
|
||||
const at = v.state.doc.lineAt(head);
|
||||
if (own) return open(at.number - 1, head - at.from);
|
||||
// The pane shows another version of the file: follow the diff to the line there.
|
||||
const from = v.state.doc;
|
||||
const known = file === fileB ? views?.b.state.doc : file === fileA ? views?.a?.state.doc : undefined;
|
||||
void (known ? Promise.resolve(known.toString()) : readWorkingText(file)).then((text) => {
|
||||
if (text === null) return open(at.number - 1, head - at.from);
|
||||
const to = Text.of(text.split(/\r?\n/));
|
||||
const line = mapLine(Chunk.build(from, to), at.number - 1, from, to);
|
||||
open(line, to.line(line + 1).text === at.text ? head - at.from : 0);
|
||||
});
|
||||
}, [left, right, path]);
|
||||
}, [left, right, path, worktreeSource]);
|
||||
|
||||
const currentChunk = (): { chunk: Chunk; index: number } | undefined => {
|
||||
const v = viewsRef.current?.b;
|
||||
@@ -456,7 +501,7 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
const identical = ready && chunkInfo.count === 0 && (attached?.a?.text() ?? statics?.a) === (attached?.b?.text() ?? statics?.b);
|
||||
// Staging applies to local changes against HEAD / the index only.
|
||||
const canStage = editableB && isWorktree(right) && left.type === "rev" && !!useVcs.getState().root;
|
||||
const hasSource = !!sideFile(left, path) || !!sideFile(right, path);
|
||||
const hasSource = !!source;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -475,6 +520,9 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
<div className={styles.toolbar} role="toolbar" aria-label={tx("Diff")}>
|
||||
<IconButton icon="arrow-up" title="Previous Difference" shortcut={shortcutLabel("diff.prevDifference")} size="small" onClick={() => step(-1)} />
|
||||
<IconButton icon="arrow-down" title="Next Difference" shortcut={shortcutLabel("diff.nextDifference")} size="small" onClick={() => step(1)} />
|
||||
{hasSource && (
|
||||
<IconButton icon="edit" title="Jump to Source" shortcut={shortcutLabel("diff.jumpToSource")} size="small" onClick={jumpToSource} />
|
||||
)}
|
||||
<span className={styles.sep} />
|
||||
<IconButton
|
||||
icon="split"
|
||||
@@ -538,9 +586,6 @@ export function DiffEditor({ input }: { input: EditorInput }) {
|
||||
)}
|
||||
<span className={styles.sep} />
|
||||
<IconButton icon="refresh" title="Refresh" size="small" onClick={() => void reload()} />
|
||||
{hasSource && (
|
||||
<IconButton icon="external-link" title="Jump to Source" shortcut={shortcutLabel("diff.jumpToSource")} size="small" onClick={jumpToSource} />
|
||||
)}
|
||||
<span className={styles.count}>
|
||||
{!ready
|
||||
? ""
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import type { EditorInput } from "@/workbench/contributions";
|
||||
import { Chunk } from "@codemirror/merge";
|
||||
import { Text } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { iconMarkup } from "@/ui";
|
||||
|
||||
const probe = vi.fn();
|
||||
const openFile = vi.fn();
|
||||
const texts: Record<string, string> = { HEAD: "a\n", abc123: "a\n" };
|
||||
let working = "a\n";
|
||||
|
||||
vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
const real = await importOriginal<typeof import("@/platform/ipc")>();
|
||||
return {
|
||||
...real,
|
||||
commands: {
|
||||
...real.commands,
|
||||
fsProbe: (path: string) => probe(path),
|
||||
vcsFileAt: (_path: string, rev: string) => Promise.resolve({ status: "ok", data: texts[rev] }),
|
||||
fsReadText: () => Promise.resolve({ status: "ok", data: working }),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/workbench/contributions", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/workbench/contributions")>()),
|
||||
editorService: () => ({ openFile }),
|
||||
}));
|
||||
|
||||
import { DiffEditor, mapLine } from "../DiffEditor";
|
||||
|
||||
/** Git Log opens a commit's file: both sides are revisions. */
|
||||
const input = {
|
||||
kind: "diff",
|
||||
key: "diff:test",
|
||||
data: {
|
||||
path: "/p/a.ts",
|
||||
left: { type: "rev", rev: "HEAD" },
|
||||
right: { type: "rev", rev: "abc123" },
|
||||
},
|
||||
} as unknown as EditorInput;
|
||||
|
||||
/** `d` attributes of an SVG, which is what tells two icons apart. */
|
||||
const paths = (html: string) => [...html.matchAll(/ d="([^"]+)"/g)].map((m) => m[1]);
|
||||
|
||||
beforeEach(() => {
|
||||
probe.mockReset();
|
||||
openFile.mockReset();
|
||||
texts.HEAD = texts.abc123 = working = "a\n";
|
||||
probe.mockResolvedValue({ status: "ok", data: { size: 3, binary: false, tooLarge: false } });
|
||||
});
|
||||
|
||||
describe("Diff toolbar", () => {
|
||||
it("puts Jump to Source right after Next Difference, in the navigation group", async () => {
|
||||
render(<DiffEditor input={input} />);
|
||||
await screen.findByRole("button", { name: "Jump to Source" });
|
||||
const toolbar = screen.getByRole("toolbar", { name: "Diff" });
|
||||
const buttons = within(toolbar).getAllByRole("button");
|
||||
expect(buttons.slice(0, 3).map((b) => b.getAttribute("aria-label"))).toEqual([
|
||||
"Previous Difference",
|
||||
"Next Difference",
|
||||
"Jump to Source",
|
||||
]);
|
||||
// One group: no separator between the three, one after them.
|
||||
expect(buttons[1]!.nextElementSibling).toBe(buttons[2]);
|
||||
expect(buttons[2]!.nextElementSibling).not.toBe(buttons[3]);
|
||||
});
|
||||
|
||||
it("shows a pencil for Jump to Source", async () => {
|
||||
render(<DiffEditor input={input} />);
|
||||
const button = await screen.findByRole("button", { name: "Jump to Source" });
|
||||
expect(paths(button.querySelector("svg")!.innerHTML)).toEqual(paths(iconMarkup("edit", 14)!));
|
||||
});
|
||||
|
||||
it("jumps to the working copy of a file shown at a revision", async () => {
|
||||
render(<DiffEditor input={input} />);
|
||||
await screen.findByRole("button", { name: "Jump to Source" });
|
||||
expect(probe).toHaveBeenCalledWith("/p/a.ts");
|
||||
});
|
||||
|
||||
it("has no Jump to Source when the file is gone from the working tree", async () => {
|
||||
probe.mockRejectedValue(new Error("no such file"));
|
||||
render(<DiffEditor input={input} />);
|
||||
await waitFor(() => expect(probe).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.queryByRole("button", { name: "Jump to Source" })).toBeNull());
|
||||
});
|
||||
});
|
||||
|
||||
const doc = (s: string) => Text.of(s.split("\n"));
|
||||
|
||||
describe("mapLine", () => {
|
||||
const from = doc("a\nb\nc\nd\ne\nf\ng\n");
|
||||
const to = doc("a\nX\nY\nb\nc\ne\nF1\nF2\ng\n");
|
||||
const map = (line: number) => mapLine(Chunk.build(from, to), line, from, to);
|
||||
|
||||
it("shifts lines past an insertion and keeps lines before it", () => {
|
||||
expect(map(0)).toBe(0);
|
||||
expect(map(1)).toBe(3);
|
||||
expect(map(2)).toBe(4);
|
||||
});
|
||||
|
||||
it("lands on the line after a removed block", () => {
|
||||
expect(map(3)).toBe(5);
|
||||
expect(map(4)).toBe(5);
|
||||
});
|
||||
|
||||
it("keeps the offset inside a changed block and shifts past it", () => {
|
||||
expect(map(5)).toBe(6);
|
||||
expect(map(6)).toBe(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Jump to Source of a revision diff", () => {
|
||||
/** Parent, commit and working copy each add a line above the previous. */
|
||||
const setUp = () => {
|
||||
texts.HEAD = "a\nb\nc\nd\n";
|
||||
texts.abc123 = "a\nX\nY\nb\nc\nd\n";
|
||||
working = "new\na\nX\nY\nb\nc\nd\n";
|
||||
};
|
||||
const pane = async (index: number) => {
|
||||
await waitFor(() => expect(document.querySelectorAll(".cm-mergeViewEditor .cm-editor").length).toBe(2));
|
||||
return EditorView.findFromDOM(document.querySelectorAll<HTMLElement>(".cm-mergeViewEditor .cm-editor")[index]!)!;
|
||||
};
|
||||
const jumpFrom = async (index: number, line: number) => {
|
||||
const v = await pane(index);
|
||||
v.dispatch({ selection: { anchor: v.state.doc.line(line + 1).from + 1 } });
|
||||
v.contentDOM.dispatchEvent(new FocusEvent("focus"));
|
||||
(await screen.findByRole("button", { name: "Jump to Source" })).click();
|
||||
await waitFor(() => expect(openFile).toHaveBeenCalled());
|
||||
return openFile.mock.calls[0];
|
||||
};
|
||||
|
||||
it("maps a line of the commit to the working copy", async () => {
|
||||
setUp();
|
||||
render(<DiffEditor input={input} />);
|
||||
expect(await jumpFrom(1, 2)).toEqual(["/p/a.ts", { selection: { line: 3, col: 1 }, focus: true }]);
|
||||
});
|
||||
|
||||
it("maps a line of the parent through the diff to the working copy", async () => {
|
||||
setUp();
|
||||
render(<DiffEditor input={input} />);
|
||||
expect(await jumpFrom(0, 2)).toEqual(["/p/a.ts", { selection: { line: 5, col: 1 }, focus: true }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user