mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-09 10:49:39 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b47d355b4b | ||
|
|
11fd64f399 | ||
|
|
8a884b6362 | ||
|
|
481d44930f |
No files matched your search
@@ -2,6 +2,8 @@ name: Language smoke
|
||||
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
|
||||
# with a quick fix, an intention, formatting, rename) per language, against the language
|
||||
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
|
||||
# Opening the fixture with its build tool and SDK (Maven / Gradle sync, the virtualenv, the
|
||||
# JDK): the ignored tests of crates/ide-host/tests/project_smoke.rs.
|
||||
# Then running and testing each fixture through the run store and openidea-server
|
||||
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts; and debugging its
|
||||
# entry point with the debugger the IDE installs (breakpoint, frames, variables, evaluate,
|
||||
@@ -107,6 +109,12 @@ jobs:
|
||||
|
||||
- run: cargo test --locked -p ide-host --test language_smoke --no-run
|
||||
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
|
||||
- if: matrix.language == 'java'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
|
||||
- if: matrix.language == 'kotlin'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
|
||||
- if: matrix.language == 'python'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact python_venv
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: cargo build --locked -p ide-host --bin openidea-server
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Opening each `fixtures/` project the way the app does, through the `ops` its commands call:
|
||||
//! the source and test roots detected from its build file, the index (files and symbols),
|
||||
//! Search Everywhere / Go to Class / Go to Symbol answered from it and kept current as files
|
||||
//! come and go, and the Maven / Gradle model.
|
||||
//!
|
||||
//! The `#[ignore]`d tests run Maven, Gradle, python3 and a JDK 21 (CI's `Language smoke`
|
||||
//! java, kotlin and python jobs):
|
||||
//! `cargo test -p ide-host --test project_smoke -- --ignored`.
|
||||
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_index::SymbolFilter;
|
||||
use ide_workspace::roots::RootKind;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const WINDOW: &str = "main";
|
||||
|
||||
struct Fixture {
|
||||
dir: &'static str,
|
||||
/// Detected roots: (project-relative path, kind).
|
||||
roots: &'static [(&'static str, RootKind)],
|
||||
/// Search Everywhere › Files: (query, file found).
|
||||
file: (&'static str, &'static str),
|
||||
/// Go to Class: (name, file declaring it).
|
||||
class: (&'static str, &'static str),
|
||||
/// Go to Symbol: (name, file declaring it).
|
||||
symbol: (&'static str, &'static str),
|
||||
/// A file created while the project is open: (path, text declaring the class `FreshWidget`).
|
||||
added: (&'static str, &'static str),
|
||||
}
|
||||
|
||||
struct Opened {
|
||||
host: Arc<Host>,
|
||||
root: PathBuf,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for entry in std::fs::read_dir(from).unwrap().flatten() {
|
||||
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
copy_dir(&src, &dst);
|
||||
} else {
|
||||
std::fs::copy(&src, &dst).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn open(fixture: &str) -> Opened {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join(fixture);
|
||||
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
|
||||
let app = tmp.path().join("app");
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: app.join("config"),
|
||||
data_dir: app.join("data"),
|
||||
local_data_dir: app.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
ops::project::open(&host, WINDOW, &root).await.unwrap();
|
||||
Opened { host, root, _tmp: tmp }
|
||||
}
|
||||
|
||||
/// Polls `check` until it holds; fails with its last answer.
|
||||
async fn until<T: std::fmt::Debug>(what: &str, timeout: Duration, mut check: impl FnMut() -> Result<(), T>) {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match check() {
|
||||
Ok(()) => return,
|
||||
Err(last) if Instant::now() > deadline => panic!("{what}: timed out after {timeout:?}; last: {last:?}"),
|
||||
Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Opened {
|
||||
fn files(&self, query: &str) -> Vec<String> {
|
||||
let found = ops::nav::find_files(&self.host, WINDOW, query.into(), 20).unwrap();
|
||||
found.into_iter().map(|f| f.rel_path).collect()
|
||||
}
|
||||
|
||||
fn symbols(&self, query: &str, filter: SymbolFilter) -> Vec<(String, String)> {
|
||||
let found = ops::nav::find_symbols(&self.host, WINDOW, query.into(), filter, 20).unwrap();
|
||||
found.into_iter().map(|s| (s.name, s.rel_path)).collect()
|
||||
}
|
||||
|
||||
fn has_symbol(&self, name: &str, file: &str, filter: SymbolFilter) -> Result<(), Vec<(String, String)>> {
|
||||
let found = self.symbols(name, filter);
|
||||
if found.iter().any(|(n, f)| n == name && f == file) { Ok(()) } else { Err(found) }
|
||||
}
|
||||
}
|
||||
|
||||
async fn smoke(f: Fixture) {
|
||||
let p = open(f.dir).await;
|
||||
until("indexing", Duration::from_secs(30), || {
|
||||
let s = ops::nav::index_status(&p.host, WINDOW).unwrap();
|
||||
if !s.indexing && s.files > 0 && s.symbols > 0 { Ok(()) } else { Err(s) }
|
||||
})
|
||||
.await;
|
||||
|
||||
let roots = ops::project::roots(&p.host, WINDOW).unwrap().roots;
|
||||
for (path, kind) in f.roots {
|
||||
assert!(
|
||||
roots.iter().any(|r| r.path == *path && r.kind == *kind),
|
||||
"{}: no {kind:?} root {path:?} in {roots:?}",
|
||||
f.dir
|
||||
);
|
||||
}
|
||||
|
||||
let files = p.files(f.file.0);
|
||||
assert!(files.iter().any(|r| r == f.file.1), "{}: files for {:?}: {files:?}", f.dir, f.file.0);
|
||||
if let Err(found) = p.has_symbol(f.class.0, f.class.1, SymbolFilter::Types) {
|
||||
panic!("{}: Go to Class {:?}: {found:?}", f.dir, f.class.0);
|
||||
}
|
||||
if let Err(found) = p.has_symbol(f.symbol.0, f.symbol.1, SymbolFilter::All) {
|
||||
panic!("{}: Go to Symbol {:?}: {found:?}", f.dir, f.symbol.0);
|
||||
}
|
||||
|
||||
// The index follows the file system while the project is open.
|
||||
let (added, text) = f.added;
|
||||
std::fs::write(p.root.join(added), text).unwrap();
|
||||
until("a new file's class", Duration::from_secs(20), || p.has_symbol("FreshWidget", added, SymbolFilter::Types))
|
||||
.await;
|
||||
std::fs::remove_file(p.root.join(added)).unwrap();
|
||||
until("a deleted file's class", Duration::from_secs(20), || {
|
||||
let found = p.symbols("FreshWidget", SymbolFilter::Types);
|
||||
if found.is_empty() { Ok(()) } else { Err(found) }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn rust_app() {
|
||||
smoke(Fixture {
|
||||
dir: "rust-app",
|
||||
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
|
||||
file: ("pars", "src/parser.rs"),
|
||||
class: ("Inventory", "src/lib.rs"),
|
||||
symbol: ("low_stock", "src/lib.rs"),
|
||||
added: ("src/fresh.rs", "pub struct FreshWidget;\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn ts_app() {
|
||||
smoke(Fixture {
|
||||
dir: "ts-app",
|
||||
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
|
||||
file: ("cart", "src/cart.ts"),
|
||||
class: ("Cart", "src/cart.ts"),
|
||||
symbol: ("formatMoney", "src/money.ts"),
|
||||
added: ("src/fresh.ts", "export class FreshWidget {}\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn python_app() {
|
||||
smoke(Fixture {
|
||||
dir: "python-app",
|
||||
roots: &[("src", RootKind::Source), ("tests", RootKind::Test)],
|
||||
file: ("models", "src/tasks/models.py"),
|
||||
class: ("TaskList", "src/tasks/models.py"),
|
||||
symbol: ("build_demo", "src/tasks/cli.py"),
|
||||
added: ("src/tasks/fresh.py", "class FreshWidget:\n pass\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn go_app() {
|
||||
smoke(Fixture {
|
||||
dir: "go-app",
|
||||
roots: &[("", RootKind::Source)],
|
||||
file: ("store", "internal/store/store.go"),
|
||||
class: ("Store", "internal/store/store.go"),
|
||||
symbol: ("writeJSON", "cmd/server/main.go"),
|
||||
added: ("internal/store/fresh.go", "package store\n\ntype FreshWidget struct{}\n"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn java_app() {
|
||||
smoke(Fixture {
|
||||
dir: "java-app",
|
||||
roots: &[("src/main/java", RootKind::Source), ("src/test/java", RootKind::Test)],
|
||||
file: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
|
||||
class: ("OrderBook", "src/main/java/com/example/orders/OrderBook.java"),
|
||||
symbol: ("revenueCents", "src/main/java/com/example/orders/OrderBook.java"),
|
||||
added: (
|
||||
"src/main/java/com/example/orders/FreshWidget.java",
|
||||
"package com.example.orders;\n\npublic class FreshWidget {}\n",
|
||||
),
|
||||
})
|
||||
.await;
|
||||
let p = open("java-app").await;
|
||||
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
let maven = model.maven.expect("a Maven model");
|
||||
assert_eq!(maven.projects.iter().map(|m| m.artifact_id.as_str()).collect::<Vec<_>>(), ["openidea-fixture-orders"]);
|
||||
assert!(maven.projects[0].dependencies.iter().any(|d| d.artifact_id == "junit-jupiter"), "{maven:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn kotlin_gradle() {
|
||||
smoke(Fixture {
|
||||
dir: "kotlin-gradle",
|
||||
roots: &[("src/main/kotlin", RootKind::Source), ("src/test/kotlin", RootKind::Test)],
|
||||
file: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
|
||||
class: ("Library", "src/main/kotlin/com/example/library/Library.kt"),
|
||||
symbol: ("giveBack", "src/main/kotlin/com/example/library/Library.kt"),
|
||||
added: ("src/main/kotlin/com/example/library/Fresh.kt", "package com.example.library\n\nclass FreshWidget\n"),
|
||||
})
|
||||
.await;
|
||||
let p = open("kotlin-gradle").await;
|
||||
let model = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
let gradle = model.gradle.expect("a Gradle model");
|
||||
assert_eq!(gradle.projects.iter().map(|g| g.name.as_str()).collect::<Vec<_>>(), ["openidea-fixture-library"]);
|
||||
}
|
||||
|
||||
/// Maven / Gradle sync (Reload in the build tool window): the build's own model.
|
||||
async fn sync(fixture: &str, tool: ide_build::BuildTool) -> ide_build::BuildModel {
|
||||
let p = open(fixture).await;
|
||||
let progress = ops::Channel::new(|_: ide_build::SyncProgress| {});
|
||||
let model =
|
||||
ops::build::build_sync(&p.host, WINDOW, tool, ide_core::RequestId(1), false, vec![], progress).await.unwrap();
|
||||
// The cached sync is what the next open shows.
|
||||
let reloaded = ops::build::build_model(&p.host, WINDOW).await.unwrap();
|
||||
assert_eq!(reloaded, model);
|
||||
model
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs Maven and a JDK"]
|
||||
async fn maven_sync() {
|
||||
let maven = sync("java-app", ide_build::BuildTool::Maven).await.maven.unwrap();
|
||||
assert_eq!(maven.sync_error, None);
|
||||
assert!(maven.synced_at.is_some());
|
||||
let junit = maven.projects[0].dependencies.iter().find(|d| d.artifact_id == "junit-jupiter").unwrap();
|
||||
// The resolved tree, not only the declaration.
|
||||
assert!(!junit.children.is_empty(), "{junit:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs Gradle and a JDK"]
|
||||
async fn gradle_sync() {
|
||||
let gradle = sync("kotlin-gradle", ide_build::BuildTool::Gradle).await.gradle.unwrap();
|
||||
assert_eq!(gradle.sync_error, None);
|
||||
assert!(gradle.synced_at.is_some());
|
||||
let tasks: Vec<&str> = gradle.projects[0].tasks.iter().map(|t| t.name.as_str()).collect();
|
||||
// The build's own tasks: the application and Kotlin plugins' too.
|
||||
for task in ["run", "compileKotlin", "test"] {
|
||||
assert!(tasks.contains(&task), "no {task} in {tasks:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs python3"]
|
||||
async fn python_venv() {
|
||||
let p = open("python-app").await;
|
||||
let made = std::process::Command::new("python3").args(["-m", "venv", ".venv"]).current_dir(&p.root).status();
|
||||
assert!(made.unwrap().success());
|
||||
// The project's virtualenv: listed first, and the interpreter the project uses.
|
||||
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Python).await.unwrap();
|
||||
assert_eq!(found.first().map(|s| s.source.as_str()), Some("venv"), "{found:?}");
|
||||
let effective = ops::sdk::sdk_python_effective(&p.host, WINDOW).unwrap().expect("an interpreter");
|
||||
assert!(effective.starts_with(p.root.join(".venv")), "{effective:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
#[ignore = "project smoke: needs JDK 21"]
|
||||
async fn jdk() {
|
||||
let p = open("java-app").await;
|
||||
let found = ops::sdk::sdk_detect(&p.host, WINDOW, ide_sdk::SdkKind::Jdk).await.unwrap();
|
||||
assert!(found.iter().any(|s| s.version.as_deref().is_some_and(|v| v.starts_with("21"))), "{found:?}");
|
||||
}
|
||||
@@ -318,7 +318,7 @@ a token.
|
||||
|
||||
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
|
||||
|
||||
223 themeable tokens: 117 in the UI theme file, 106 in the editor colour scheme file.
|
||||
224 themeable tokens: 117 in the UI theme file, 107 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -420,6 +420,7 @@ a token.
|
||||
| `--syn-number` | `#2aacb8` | `#1750eb` |
|
||||
| `--syn-punct` | `#bcbec4` | `#080808` |
|
||||
| `--syn-variable` | `#bcbec4` | `#080808` |
|
||||
| `--syn-global` | `var(--syn-variable)` | `var(--syn-variable)` |
|
||||
| `--syn-param` | `#bcbec4` | `#080808` |
|
||||
| `--syn-property` | `#c77dbb` | `#871094` |
|
||||
| `--syn-constant` | `#c77dbb` | `#871094` |
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"--syn-number": "#7fc8f8",
|
||||
"--syn-punct": "#a9b4c2",
|
||||
"--syn-variable": "#d9dfe8",
|
||||
"--syn-global": "#d9dfe8",
|
||||
"--syn-param": "#d9dfe8",
|
||||
"--syn-property": "#b9a3f0",
|
||||
"--syn-constant": "#e58fa3",
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
/**
|
||||
* Push dialog: the files changed by the outgoing commits (the selected commit's, or all of
|
||||
* them), with the selected file's diff beside the tree. The diff stays inside the dialog,
|
||||
* which is modal.
|
||||
* which is modal. A single commit also shows its message, author and time.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ChangedFile, Commit, RevisionChange } from "@/bindings";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { tx } from "@/platform/i18n";
|
||||
import { Button, Spinner, T } from "@/ui";
|
||||
import { Button, IconButton, Spinner, T } from "@/ui";
|
||||
import { ComparisonTree } from "./ComparisonTree";
|
||||
import { buildFileTree } from "./fileTree";
|
||||
import { toLogChanges, type LogChange } from "./logChanges";
|
||||
import { KIND_COLOR, fileCount } from "./model";
|
||||
import { flatFileNodes, toLogChanges, type LogChange } from "./logChanges";
|
||||
import { KIND_COLOR, dirName, fileCount } from "./model";
|
||||
import styles from "./common.module.css";
|
||||
|
||||
const GROUP_KEY = "openidea.vcs.push.groupByDirectory";
|
||||
|
||||
function readGroupByDirectory(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(GROUP_KEY) !== "false";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Identifies what `PushChanges` shows; render it with this `key` so a new selection starts afresh. */
|
||||
export const pushChangesKey = (repo: string | null, commits: readonly Commit[]) =>
|
||||
`${repo ?? ""}:${commits.map((c) => c.id).join(" ")}`;
|
||||
@@ -34,6 +44,7 @@ export function PushChanges({
|
||||
const [changes, setChanges] = useState<LogChange[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [current, setCurrent] = useState<LogChange | null>(null);
|
||||
const [byDirectory, setByDirectory] = useState(readGroupByDirectory);
|
||||
|
||||
useEffect(() => {
|
||||
if (!commits.length) return;
|
||||
@@ -50,7 +61,18 @@ export function PushChanges({
|
||||
};
|
||||
}, [repo, commits]);
|
||||
|
||||
const tree = useMemo(() => (changes ? buildFileTree(changes, (f) => f.path) : []), [changes]);
|
||||
const tree = useMemo(
|
||||
() => (!changes ? [] : byDirectory ? buildFileTree(changes, (f) => f.path) : flatFileNodes(changes, (f) => f.path)),
|
||||
[changes, byDirectory],
|
||||
);
|
||||
const toggleGrouping = () => {
|
||||
setByDirectory(!byDirectory);
|
||||
try {
|
||||
localStorage.setItem(GROUP_KEY, String(!byDirectory));
|
||||
} catch {
|
||||
/* kept for this dialog only */
|
||||
}
|
||||
};
|
||||
const count = commits.length;
|
||||
if (!count) return null;
|
||||
return (
|
||||
@@ -65,7 +87,16 @@ export function PushChanges({
|
||||
<T>Show All Commits</T>
|
||||
</Button>
|
||||
)}
|
||||
<IconButton
|
||||
icon="folder"
|
||||
size="small"
|
||||
title={tx("Group by Directory")}
|
||||
pressed={byDirectory}
|
||||
onClick={toggleGrouping}
|
||||
className={styles.pushGrouping}
|
||||
/>
|
||||
</div>
|
||||
{count === 1 && <PushCommitDetails commit={commits[0]!} />}
|
||||
{error ? (
|
||||
<div className={styles.error}>{error}</div>
|
||||
) : !changes ? (
|
||||
@@ -80,9 +111,31 @@ export function PushChanges({
|
||||
diffFor={(c) => c.diff}
|
||||
getColor={(c) => KIND_COLOR[c.kind === "untracked" ? "added" : c.kind]}
|
||||
ariaLabel="Changed files"
|
||||
renderDetails={(n) => (n.file ? null : <span className={styles.fileDir}>{n.count}</span>)}
|
||||
renderDetails={(n) =>
|
||||
n.file ? (
|
||||
byDirectory ? null : (
|
||||
<span className={styles.fileDir}>{dirName(n.file.path)}</span>
|
||||
)
|
||||
) : (
|
||||
<span className={styles.fileDir}>{n.count}</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The selected commit's full message, revision, author and time. */
|
||||
function PushCommitDetails({ commit }: { commit: Commit }) {
|
||||
const date = commit.timeMs ? new Date(commit.timeMs).toLocaleString() : null;
|
||||
return (
|
||||
<section className={styles.pushCommitDetails} aria-label={tx("Commit details")}>
|
||||
<div className={styles.pushCommitMessage}>{commit.message.trimEnd() || commit.summary}</div>
|
||||
<div className={styles.fileDir}>
|
||||
{commit.shortId} {commit.author} <{commit.email}>
|
||||
{date ? " " + tx`on ${date}` : ""}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,16 @@ vi.mock("@/platform/ipc", async (importOriginal) => {
|
||||
import { PushChanges } from "../PushChanges";
|
||||
|
||||
const commit = (id: string, parent: string): Commit =>
|
||||
({ id, shortId: id.slice(0, 7), parents: [parent], summary: id, author: "me", timeMs: 0 }) as unknown as Commit;
|
||||
({
|
||||
id,
|
||||
shortId: id.slice(0, 7),
|
||||
parents: [parent],
|
||||
summary: id,
|
||||
message: id,
|
||||
author: "me",
|
||||
email: "[email protected]",
|
||||
timeMs: 0,
|
||||
}) as unknown as Commit;
|
||||
const newer = commit("bbbbbbbbbb", "aaaaaaaaaa");
|
||||
const older = commit("aaaaaaaaaa", "9999999999");
|
||||
|
||||
@@ -41,4 +50,36 @@ describe("Push dialog changes", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show All Commits" }));
|
||||
expect(showAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the selected commit's message and author, and lists files flat on request", async () => {
|
||||
localStorage.clear();
|
||||
files.mockResolvedValue({
|
||||
status: "ok",
|
||||
data: [{ path: "src/orders/service.py", oldPath: null, kind: "added" }],
|
||||
});
|
||||
const one = { ...older, message: "Add order service\n\nIntroduces the service.\n", timeMs: 1_700_000_000_000 };
|
||||
render(<PushChanges repo="/r" commits={[one]} />);
|
||||
const details = screen.getByRole("region", { name: "Commit details" });
|
||||
expect(details.textContent).toContain("Add order service\n\nIntroduces the service.");
|
||||
expect(details.textContent).toContain("aaaaaaa me <[email protected]> on ");
|
||||
expect(await screen.findByText("src/orders")).toBeTruthy();
|
||||
expect(screen.getAllByRole("treeitem")).toHaveLength(2);
|
||||
const grouping = screen.getByRole("button", { name: "Group by Directory" });
|
||||
expect(grouping.getAttribute("aria-pressed")).toBe("true");
|
||||
fireEvent.click(grouping);
|
||||
expect(grouping.getAttribute("aria-pressed")).toBe("false");
|
||||
// Flat: one row, the file, with its directory beside the name.
|
||||
const rows = screen.getAllByRole("treeitem");
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.textContent).toContain("service.py");
|
||||
expect(rows[0]!.textContent).toContain("src/orders");
|
||||
expect(localStorage.getItem("openidea.vcs.push.groupByDirectory")).toBe("false");
|
||||
});
|
||||
|
||||
it("shows no commit details for several commits", async () => {
|
||||
combined.mockResolvedValue({ status: "ok", data: [] });
|
||||
render(<PushChanges repo="/r" commits={[newer, older]} />);
|
||||
expect(await screen.findByText(/0 files in 2 commits/)).toBeTruthy();
|
||||
expect(screen.queryByRole("region", { name: "Commit details" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -143,6 +143,27 @@
|
||||
padding: 0 14px 6px;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.pushGrouping {
|
||||
margin-left: auto;
|
||||
}
|
||||
.pushCommitDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 96px;
|
||||
margin: 0 14px 8px;
|
||||
padding: 6px 8px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-small);
|
||||
-webkit-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
.pushCommitMessage {
|
||||
color: var(--fg-strong);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.todoList {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
|
||||
@@ -477,6 +477,7 @@
|
||||
"Comment": "评论",
|
||||
"Comment:": "注释:",
|
||||
"Commit": "提交",
|
||||
"Commit details": "提交详细信息",
|
||||
"Commit Anyway": "仍然提交",
|
||||
"Commit Changelist…": "提交更改列表…",
|
||||
"Commit Checks": "提交检查",
|
||||
|
||||
Reference in new issue
Block a user