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
8 changes: 8 additions & 0 deletions .github/workflows/ci-macos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,16 @@ jobs:
key: swiftpm-${{ runner.os }}-6.2-${{ hashFiles('Package.resolved') }}

- name: Run Swift tests
id: swift-tests-primary
continue-on-error: true
timeout-minutes: 8
run: ./scripts/test-macos.sh

- name: Retry Swift tests after a failed attempt
if: steps.swift-tests-primary.outcome == 'failure'
timeout-minutes: 8
run: ./scripts/test-macos.sh --skip-build

rust-tests:
name: Rust Core and database tests
needs: changes
Expand Down
27 changes: 25 additions & 2 deletions rust/lithe-core/src/git/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ use crate::protocol::{
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::io::Write;
#[cfg(target_os = "windows")]
use std::os::windows::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::thread;
Expand Down Expand Up @@ -526,7 +528,7 @@ fn execute_git_with_options(
disable_optional_locks: bool,
) -> Result<GitCommandResponse, CoreError> {
crate::protocol::cancellation::check()?;
let mut process = Command::new("git");
let mut process = git_process();
process.args(arguments).current_dir(root);
if disable_optional_locks {
process.env("GIT_OPTIONAL_LOCKS", "0");
Expand Down Expand Up @@ -599,6 +601,21 @@ fn execute_git_with_options(
})
}

fn git_process() -> Command {
let mut process = Command::new("git");
#[cfg(target_os = "windows")]
process.creation_flags(git_process_creation_flags());
process
}

#[cfg(target_os = "windows")]
fn git_process_creation_flags() -> u32 {
// Git runs as an IDE background task; attaching a console can briefly open
// the user's default terminal whenever status or repository data refreshes.
const CREATE_NO_WINDOW: u32 = 0x08000000;
CREATE_NO_WINDOW
}

/// Builds a structured working-tree, staged, untracked, or commit diff.
pub fn diff(request: GitDiffRequest) -> Result<GitDiffResponse, CoreError> {
if request.pathspecs.is_empty() || request.pathspecs.iter().any(|path| !is_safe_pathspec(path))
Expand Down Expand Up @@ -2752,7 +2769,7 @@ fn run_git(directory: &Path, arguments: &[&str]) -> Result<std::process::Output,
// may otherwise refresh its optional index data while answering a query,
// which emits `.git/index` events into the native watcher and can trigger
// another status refresh.
Command::new("git")
git_process()
.env("GIT_OPTIONAL_LOCKS", "0")
.args(arguments)
.current_dir(directory)
Expand Down Expand Up @@ -2818,6 +2835,12 @@ mod tests {
use super::{line_similarity, pair_diff_entries, parse_diff, DiffEntry, MAX_ALIGNMENT_CELLS};
use serde_json::Value;

#[cfg(target_os = "windows")]
#[test]
fn background_git_processes_do_not_create_windows_console() {
assert_eq!(super::git_process_creation_flags(), 0x08000000);
}

fn entries(texts: &[&str]) -> Vec<DiffEntry> {
texts
.iter()
Expand Down
17 changes: 15 additions & 2 deletions windows/tauri/src-tauri/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ fn copy_path(source: &std::path::Path, destination: &std::path::Path) -> Result<
}

#[tauri::command]
pub fn create_app_window(app: AppHandle, request: Option<Value>) -> Result<String, String> {
pub async fn create_app_window(app: AppHandle, request: Option<Value>) -> Result<String, String> {
let label = format!("workspace-{}", WINDOW_ID.fetch_add(1, Ordering::Relaxed));
let mut query = url::form_urlencoded::Serializer::new(String::new());
if let Some(request) = request.and_then(|value| value.as_object().cloned()) {
Expand Down Expand Up @@ -298,10 +298,23 @@ pub fn create_app_window(app: AppHandle, request: Option<Value>) -> Result<Strin

#[cfg(test)]
mod tests {
use super::{cli_payloads, copy_path, unique_destination};
use super::{cli_payloads, copy_path, create_app_window, unique_destination};
use std::fs;
use std::future::Future;
use std::path::PathBuf;

fn assert_async_window_command<F, Fut>(_command: F)
where
F: Fn(tauri::AppHandle, Option<serde_json::Value>) -> Fut,
Fut: Future<Output = Result<String, String>>,
{
}

#[test]
fn creates_app_windows_outside_the_synchronous_ipc_handler() {
assert_async_window_command(create_app_window);
}

#[test]
fn parses_path_and_web_cli_arguments() {
let payloads = cli_payloads([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const ProjectNameMenu = () => {
id: "open-folder",
label: "Open Folder in New Tab",
icon: <FolderOpen />,
onClick: () => handleOpenFolder(),
onClick: () => handleOpenFolder({ destination: "this-window" }),
},
{
id: "add-folder-to-workspace",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import { describe, expect, test } from "bun:test";
import { defaultSettings } from "@/features/settings/config/default-settings";
import {
chooseProjectOpenDestination,
executeProjectOpenDecision,
hasOpenProjectWorkspace,
type ProjectOpenDestination,
type ProjectOpenDestinationServices,
type ProjectOpenPromptResult,
} from "./project-open-destination";

function createServices(options?: {
askWhereToOpenProjects?: boolean;
openFoldersInNewWindow?: boolean;
promptResult?: ProjectOpenPromptResult | null;
language?: "en-US" | "zh-CN";
}) {
const promptRequests: Array<{ projectName: string; language: "en-US" | "zh-CN" }> = [];
const updates: Array<[string, boolean]> = [];

const services: ProjectOpenDestinationServices = {
getSettings: () => ({
askWhereToOpenProjects: options?.askWhereToOpenProjects ?? true,
openFoldersInNewWindow: options?.openFoldersInNewWindow ?? true,
displayLanguage: options?.language ?? "en-US",
}),
prompt: async (request) => {
promptRequests.push(request);
return options?.promptResult ?? null;
},
updateSetting: async (key, value) => {
updates.push([key, value]);
},
};

return { services, promptRequests, updates };
}

describe("project open destination", () => {
test("asks where to open projects by default", () => {
expect(defaultSettings.askWhereToOpenProjects).toBe(true);
});

test("opens directly in this window when no workspace is open", async () => {
const { services, promptRequests } = createServices();

const decision = await chooseProjectOpenDestination(
{ projectName: "Lithe", hasOpenWorkspace: false },
services,
);

expect(decision).toEqual({ destination: "this-window", rememberAfterOpen: false });
expect(promptRequests).toEqual([]);
});

test.each([
[true, "new-window"],
[false, "this-window"],
] as const)("uses the remembered destination when prompting is disabled", async (openNew, expected) => {
const { services, promptRequests } = createServices({
askWhereToOpenProjects: false,
openFoldersInNewWindow: openNew,
});

const decision = await chooseProjectOpenDestination(
{ projectName: "Lithe", hasOpenWorkspace: true },
services,
);

expect(decision).toEqual({ destination: expected, rememberAfterOpen: false });
expect(promptRequests).toEqual([]);
});

test("returns an unchecked selection without changing settings", async () => {
const selected: ProjectOpenDestination = "new-window";
const { services, promptRequests, updates } = createServices({
language: "zh-CN",
promptResult: { destination: selected, doNotAskAgain: false },
});

const decision = await chooseProjectOpenDestination(
{ projectName: "Lithe", hasOpenWorkspace: true },
services,
);

expect(decision).toEqual({ destination: selected, rememberAfterOpen: false });
expect(promptRequests).toEqual([{ projectName: "Lithe", language: "zh-CN" }]);
expect(updates).toEqual([]);
});

test.each(["new-window", "this-window"] as const)(
"defers a checked destination until the project opens",
async (selected) => {
const { services, updates } = createServices({
promptResult: { destination: selected, doNotAskAgain: true },
});

const decision = await chooseProjectOpenDestination(
{ projectName: "Lithe", hasOpenWorkspace: true },
services,
);

expect(decision).toEqual({ destination: selected, rememberAfterOpen: true });
expect(updates).toEqual([]);
},
);

test("cancels without opening or changing settings", async () => {
const { services, updates } = createServices({ promptResult: null });

const decision = await chooseProjectOpenDestination(
{ projectName: "Lithe", hasOpenWorkspace: true },
services,
);

expect(decision).toBeNull();
expect(updates).toEqual([]);
});

test("uses an explicit destination without prompting or remembering", async () => {
const { services, promptRequests, updates } = createServices({
promptResult: { destination: "new-window", doNotAskAgain: true },
});

const decision = await chooseProjectOpenDestination(
{
projectName: "Lithe",
hasOpenWorkspace: true,
explicitDestination: "this-window",
},
services,
);

expect(decision).toEqual({ destination: "this-window", rememberAfterOpen: false });
expect(promptRequests).toEqual([]);
expect(updates).toEqual([]);
});

test("persists a checked destination only after a successful open", async () => {
const { services, updates } = createServices();
const events: string[] = [];
const recordingServices: ProjectOpenDestinationServices = {
...services,
updateSetting: async (key, value) => {
events.push(`setting:${key}:${value}`);
updates.push([key, value]);
},
};

const opened = await executeProjectOpenDecision(
{ destination: "new-window", rememberAfterOpen: true },
async (destination) => {
events.push(`open:${destination}`);
return true;
},
recordingServices,
);

expect(opened).toBe(true);
expect(events).toEqual([
"open:new-window",
"setting:openFoldersInNewWindow:true",
"setting:askWhereToOpenProjects:false",
]);
});

test.each([false, "throw"] as const)(
"does not persist a checked destination when opening fails",
async (failureMode) => {
const { services, updates } = createServices();
const execute = () =>
executeProjectOpenDecision(
{ destination: "this-window", rememberAfterOpen: true },
async () => {
if (failureMode === "throw") throw new Error("open failed");
return false;
},
services,
);

if (failureMode === "throw") {
await expect(execute()).rejects.toThrow("open failed");
} else {
expect(await execute()).toBe(false);
}
expect(updates).toEqual([]);
},
);

test("treats an existing project tab as an open workspace during initialization", () => {
expect(
hasOpenProjectWorkspace({
rootFolderPath: undefined,
fileCount: 0,
projectTabCount: 1,
}),
).toBe(true);
expect(
hasOpenProjectWorkspace({
rootFolderPath: undefined,
fileCount: 0,
projectTabCount: 0,
}),
).toBe(false);
});
});
Loading
Loading