Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions crates/leveler-app/src/interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -859,7 +859,9 @@ impl InProcessRuntimeClient {
.await;
match result {
Ok((_session_id, report)) => {
let diff = compute_diff(&repo, false);
// with_patch=true: the WebUI/TUI show the actual code diff
// (not just file counts) inline after each turn.
let diff = compute_diff(&repo, true);
let _ = events.send(RuntimeEvent::DiffUpdated { diff: diff.clone() });
if cancel_probe.is_cancelled() {
let _ = events.send(RuntimeEvent::TurnCancelled);
Expand Down Expand Up @@ -935,7 +937,7 @@ impl InProcessRuntimeClient {
format!("对话已回滚,但工作区文件回滚失败: {error}"),
);
} else {
let diff = compute_diff(&self.app.layout.repo_root, false);
let diff = compute_diff(&self.app.layout.repo_root, true);
let _ = self
.events_for(&session_id)
.send(RuntimeEvent::DiffUpdated { diff });
Expand Down
29 changes: 17 additions & 12 deletions crates/leveler-app/tests/direct_verification.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ fn gate_config(unix_body: &str, windows_body: &str) -> String {

#[tokio::test]
async fn direct_run_fails_when_post_edit_verification_fails() {
let patch = "*** Begin Patch\n*** Update File: README.md\n old\n+new\n*** End Patch";
let patch = "*** Begin Patch\n*** Update File: src/lib.rs\n old\n+new\n*** End Patch";
let server = MockServer::start(vec![
sse(vec![
tool_call_frame("apply_patch", serde_json::json!({ "patch": patch })),
Expand All @@ -146,7 +146,8 @@ async fn direct_run_fails_when_post_edit_verification_fails() {
.await;

let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("README.md"), "old\n").unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join("src/lib.rs"), "old\n").unwrap();
std::fs::create_dir_all(tmp.path().join(".leveler")).unwrap();
std::fs::write(
tmp.path().join(".leveler/config.yaml"),
Expand Down Expand Up @@ -202,7 +203,7 @@ async fn direct_run_fails_when_post_edit_verification_fails() {

#[tokio::test]
async fn direct_content_run_fails_when_post_edit_verification_fails() {
let patch = "*** Begin Patch\n*** Update File: README.md\n old\n+new\n*** End Patch";
let patch = "*** Begin Patch\n*** Update File: src/lib.rs\n old\n+new\n*** End Patch";
let server = MockServer::start(vec![
sse(vec![
tool_call_frame("apply_patch", serde_json::json!({ "patch": patch })),
Expand All @@ -213,7 +214,8 @@ async fn direct_content_run_fails_when_post_edit_verification_fails() {
.await;

let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("README.md"), "old\n").unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join("src/lib.rs"), "old\n").unwrap();
std::fs::create_dir_all(tmp.path().join(".leveler")).unwrap();
std::fs::write(
tmp.path().join(".leveler/config.yaml"),
Expand Down Expand Up @@ -384,7 +386,7 @@ async fn direct_run_without_gating_verification_is_completed_unverified() {

#[tokio::test]
async fn direct_content_run_emits_verification_events() {
let patch = "*** Begin Patch\n*** Update File: README.md\n old\n+new\n*** End Patch";
let patch = "*** Begin Patch\n*** Update File: src/lib.rs\n old\n+new\n*** End Patch";
let server = MockServer::start(vec![
sse(vec![
tool_call_frame("apply_patch", serde_json::json!({ "patch": patch })),
Expand All @@ -395,7 +397,8 @@ async fn direct_content_run_emits_verification_events() {
.await;

let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("README.md"), "old\n").unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join("src/lib.rs"), "old\n").unwrap();
std::fs::create_dir_all(tmp.path().join(".leveler")).unwrap();
std::fs::write(
tmp.path().join(".leveler/config.yaml"),
Expand Down Expand Up @@ -462,9 +465,9 @@ async fn direct_content_run_emits_verification_events() {

#[tokio::test]
async fn direct_run_repairs_once_after_failed_verification() {
let first_patch = "*** Begin Patch\n*** Update File: README.md\n old\n+bad\n*** End Patch";
let first_patch = "*** Begin Patch\n*** Update File: code.rs\n old\n+bad\n*** End Patch";
let repair_patch =
"*** Begin Patch\n*** Update File: README.md\n old\n-bad\n+fixed\n*** End Patch";
"*** Begin Patch\n*** Update File: code.rs\n old\n-bad\n+fixed\n*** End Patch";
let server = MockServer::start(vec![
sse(vec![
tool_call_frame("apply_patch", serde_json::json!({ "patch": first_patch })),
Expand All @@ -486,11 +489,13 @@ async fn direct_run_repairs_once_after_failed_verification() {
.await;

let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("README.md"), "old\n").unwrap();
// A root-level `.rs` file is build-relevant (keeps the gate active) and its
// bare name avoids path-separator quirks in the cross-platform gate command.
std::fs::write(tmp.path().join("code.rs"), "old\n").unwrap();
std::fs::create_dir_all(tmp.path().join(".leveler")).unwrap();
std::fs::write(
tmp.path().join(".leveler/config.yaml"),
gate_config("grep fixed README.md", "findstr fixed README.md"),
gate_config("grep fixed code.rs", "findstr fixed code.rs"),
)
.unwrap();
write_config(tmp.path(), &server.base_url());
Expand Down Expand Up @@ -520,9 +525,9 @@ async fn direct_run_repairs_once_after_failed_verification() {
.await
.expect("repair should make verification pass");

assert_eq!(outcome.modified_files, vec!["README.md"]);
assert_eq!(outcome.modified_files, vec!["code.rs"]);
assert!(
std::fs::read_to_string(tmp.path().join("README.md"))
std::fs::read_to_string(tmp.path().join("code.rs"))
.unwrap()
.contains("fixed")
);
Expand Down
18 changes: 16 additions & 2 deletions crates/leveler-cli/src/run_cmds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -878,7 +878,19 @@ pub(crate) async fn cmd_web(
let client = LocalSocketRuntimeClient::connect_tcp(daemon_addr, token.clone()).await?;
let service: Arc<dyn leveler_local_transport::LocalRuntimeService> =
Arc::new(DaemonService(client));
let server = leveler_web::bind(service, addr, token.clone()).await?;
// Aggregation is available in --connect mode too, so the WebUI's
// "open project" flow works (POST /api/projects) instead of 404ing.
// The daemon is the primary behind the RouterService; added projects
// get their own probe-or-spawn daemons like the in-process path.
let router = leveler_web::RouterService::new(service, layout.repo_root.clone());
let registry_path = web_projects_registry_path();
let manager = leveler_web::ProjectManager::new(
router.clone(),
registry_path,
std::env::current_exe().ok(),
);
tokio::spawn(manager.clone().load_registry());
let server = leveler_web::bind_multi(router, manager, addr, token.clone()).await?;
(server, None, token)
}
None => {
Expand Down Expand Up @@ -1321,7 +1333,9 @@ mod tui_runtime_selection_tests {
}
}

#[cfg(test)]
// Unix sockets + the loopback TCP daemon are unix-only; on Windows the
// transport returns Unavailable by design, so these binding tests are gated.
#[cfg(all(test, unix))]
mod daemon_bind_tests {
use super::*;
use leveler_client_protocol::{
Expand Down
6 changes: 5 additions & 1 deletion crates/leveler-engine/src/baseline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,11 @@ pub(crate) async fn git_ok(repo: &Path, args: &[&str], cancellation: &Cancellati
.unwrap_or(false)
}

#[cfg(test)]
// These tests drive git worktrees and a shell-based marker gate as fixtures;
// the baseline-reconciliation logic they cover is platform-independent, and the
// Windows shell/program-resolution quirks are not worth reproducing, so the
// module is gated to unix (git + /bin/sh are always present there).
#[cfg(all(test, unix))]
mod tests {
use super::*;
use leveler_verifier::{CheckKind, VerificationCommand};
Expand Down
37 changes: 36 additions & 1 deletion crates/leveler-local-transport/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,15 @@ mod unsupported {
))
}

pub async fn connect_tcp(
_addr: std::net::SocketAddr,
_token: impl Into<String>,
) -> Result<Self, TransportError> {
Err(TransportError::Unavailable(
"the TCP daemon is not supported on this platform".to_string(),
))
}

pub async fn create_session(
&self,
_request: CreateSessionRequest,
Expand Down Expand Up @@ -937,10 +946,36 @@ mod unsupported {
LocalSocketRuntimeClient::create_session(self, request).await
}
}

pub struct TcpRuntimeServer;

impl TcpRuntimeServer {
pub async fn bind(
_addr: std::net::SocketAddr,
_token: impl Into<String>,
_runtime: Arc<dyn LocalRuntimeService>,
) -> Result<Self, TransportError> {
Err(TransportError::Unavailable(
"the TCP daemon is not supported on this platform".to_string(),
))
}

pub fn local_addr(&self) -> Result<std::net::SocketAddr, TransportError> {
Err(TransportError::Unavailable(
"the TCP daemon is not supported on this platform".to_string(),
))
}

pub async fn serve(self, _shutdown: CancellationToken) -> Result<(), TransportError> {
Err(TransportError::Unavailable(
"the TCP daemon is not supported on this platform".to_string(),
))
}
}
}

#[cfg(not(unix))]
pub use unsupported::{LocalSocketRuntimeClient, LocalSocketServer};
pub use unsupported::{LocalSocketRuntimeClient, LocalSocketServer, TcpRuntimeServer};

#[cfg(all(test, unix))]
mod tests {
Expand Down
128 changes: 128 additions & 0 deletions crates/leveler-web/src/browse.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! Filesystem directory browser for the "open project" modal.
//!
//! Browsers cannot hand JavaScript a real absolute path (the native folder
//! picker only yields an opaque handle), yet registering a project needs one.
//! This token-gated endpoint lets the WebUI walk the *server's* filesystem —
//! which is the user's own machine — one directory at a time, so the modal can
//! offer a click-through picker instead of a raw path field.

use std::path::{Path, PathBuf};

use axum::Json;
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize)]
pub(crate) struct ListQuery {
/// Absolute directory to list. Absent or empty starts at the home dir.
path: Option<String>,
}

#[derive(Debug, Serialize)]
pub(crate) struct DirEntry {
name: String,
path: String,
is_repo: bool,
hidden: bool,
}

#[derive(Debug, Serialize)]
pub(crate) struct ListResponse {
/// The canonical directory that was listed.
path: String,
/// Its parent, or null at the filesystem root.
parent: Option<String>,
entries: Vec<DirEntry>,
}

/// `GET /api/fs/list?path=<absolute>` — the immediate sub-directories of one
/// directory (files are omitted), each flagged when it is a git repository.
pub(crate) async fn list_dir(
Query(query): Query<ListQuery>,
) -> Result<Json<ListResponse>, Response> {
let start = match query
.path
.as_deref()
.map(str::trim)
.filter(|p| !p.is_empty())
{
Some(path) => PathBuf::from(path),
None => home_dir(),
};

let canonical = start.canonicalize().map_err(|error| {
let status = match error.kind() {
std::io::ErrorKind::NotFound => StatusCode::NOT_FOUND,
std::io::ErrorKind::PermissionDenied => StatusCode::FORBIDDEN,
_ => StatusCode::BAD_REQUEST,
};
error_response(status, format!("cannot open {}: {error}", start.display()))
})?;
if !canonical.is_dir() {
return Err(error_response(
StatusCode::BAD_REQUEST,
format!("{} is not a directory", canonical.display()),
));
}

let mut entries = read_subdirectories(&canonical).map_err(|error| {
error_response(
StatusCode::FORBIDDEN,
format!("cannot list {}: {error}", canonical.display()),
)
})?;
// Non-hidden first, then case-insensitive by name — the order the picker shows.
entries.sort_by(|a, b| {
a.hidden
.cmp(&b.hidden)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});

Ok(Json(ListResponse {
path: canonical.to_string_lossy().into_owned(),
parent: canonical
.parent()
.map(|parent| parent.to_string_lossy().into_owned()),
entries,
}))
}

/// Read one directory's immediate sub-directories. Entries that cannot be
/// stat'd (a race, a broken symlink, a permission hole) are skipped rather
/// than failing the whole listing.
fn read_subdirectories(dir: &Path) -> std::io::Result<Vec<DirEntry>> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir)? {
let Ok(entry) = entry else { continue };
let path = entry.path();
// `file_type()` follows no symlink; resolve so a symlinked directory
// still counts as one and its `.git` probe below is meaningful.
let is_dir = std::fs::metadata(&path)
.map(|m| m.is_dir())
.unwrap_or(false);
if !is_dir {
continue;
}
let name = entry.file_name().to_string_lossy().into_owned();
out.push(DirEntry {
is_repo: path.join(".git").exists(),
hidden: name.starts_with('.'),
path: path.to_string_lossy().into_owned(),
name,
});
}
Ok(out)
}

fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(if cfg!(windows) { "C:\\" } else { "/" }))
}

fn error_response(status: StatusCode, message: String) -> Response {
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
1 change: 1 addition & 0 deletions crates/leveler-web/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
#![forbid(unsafe_code)]

mod auth;
mod browse;
mod projects;
mod protocol;
mod repo;
Expand Down
12 changes: 8 additions & 4 deletions crates/leveler-web/src/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -457,9 +457,10 @@ mod tests {
ClientCommand, ClientError, InteractiveRuntimeClient, RuntimeEvent, SessionId,
UiSessionSnapshot, mock::MockRuntimeClient,
};
use leveler_local_transport::{
CreateSessionRequest, LocalRuntimeService, LocalSocketServer, SessionBootstrap,
};
use leveler_local_transport::{CreateSessionRequest, LocalRuntimeService, SessionBootstrap};
// Unix-socket daemon fixture is unix-only (Windows stubs return Unavailable).
#[cfg(unix)]
use leveler_local_transport::LocalSocketServer;

/// Minimal primary service: the manager tests never exercise commands.
struct StubService {
Expand Down Expand Up @@ -517,7 +518,8 @@ mod tests {
}

/// Bind AND serve a stub daemon on `socket` — a bound-but-unserved socket
/// would hang the client's first request forever.
/// would hang the client's first request forever. Unix-socket only.
#[cfg(unix)]
async fn serve_stub_daemon(socket: &Path) -> tokio_util::sync::CancellationToken {
let server = LocalSocketServer::bind(socket, StubService::new())
.await
Expand All @@ -530,6 +532,7 @@ mod tests {
shutdown
}

#[cfg(unix)]
#[tokio::test]
async fn probe_attaches_to_a_live_daemon_and_registry_persists() {
let dir = tempfile::tempdir().unwrap();
Expand Down Expand Up @@ -589,6 +592,7 @@ mod tests {
assert_eq!(list[1].status, ProjectStatus::Offline);
}

#[cfg(unix)]
#[tokio::test]
async fn load_registry_reattaches_persisted_projects() {
let dir = tempfile::tempdir().unwrap();
Expand Down
Loading