Skip to content
7 changes: 2 additions & 5 deletions apps/mobile/src/features/files/ZipArchiveView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,7 @@ it("keeps the ZIP filter and archive open when native Back closes a selected ent
'[aria-label="Filter files in ZIP"]',
)!;
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(
filter,
"first",
);
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(filter, "frst");
filter.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () =>
Expand All @@ -279,7 +276,7 @@ it("keeps the ZIP filter and archive open when native Back closes a selected ent
expect(screen.container.querySelector('[aria-label="first.txt"]')).not.toBeNull();
expect(
screen.container.querySelector<HTMLInputElement>('[aria-label="Filter files in ZIP"]')?.value,
).toBe("first");
).toBe("frst");
});

it("explains why an encrypted ZIP cannot be browsed", async () => {
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/src/features/files/zipTree.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { scoreFilePathMatch } from "@t3tools/shared/searchRanking";

export interface ZipEntry {
readonly path: string;
readonly name: string;
Expand Down Expand Up @@ -89,7 +91,7 @@ function flatten(
query: string,
): boolean {
if (node.entry) {
if (query && !node.entry.path.toLowerCase().includes(query)) return false;
if (query && scoreFilePathMatch(node.entry.path, query) === null) return false;
output.push({ node, depth });
return true;
}
Expand Down
24 changes: 21 additions & 3 deletions apps/mobile/src/features/projects/AddProjectScreen.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,7 @@ describe("local project navigation", () => {
describe("local folder path", () => {
function findPathInput(
node: ReactNode,
): { value: string; onChangeText: (value: string) => void } | null {
): { value: string; onChangeText: (value: string) => void; onSubmit: () => void } | null {
if (Array.isArray(node)) {
for (const child of node) {
const input = findPathInput(child);
Expand All @@ -679,7 +679,8 @@ describe("local folder path", () => {
return null;
}
const { value, onChangeText, onSubmit } = node.props;
if (typeof value === "string" && onChangeText && onSubmit) return { value, onChangeText };
if (typeof value === "string" && onChangeText && onSubmit)
return { value, onChangeText, onSubmit };
return findPathInput(node.props.children);
}

Expand All @@ -695,13 +696,18 @@ describe("local folder path", () => {
const root = createRoot(document.createElement("div"));
const renderedPaths: string[] = [];
let input: ReturnType<typeof findPathInput> = null;
let submit: (() => void) | null = null;
function LocalFolderObserver(props: { environmentId?: string }) {
input = findPathInput(AddProjectLocalFolderScreen(props));
const content = AddProjectLocalFolderScreen(props);
input = findPathInput(content);
submit = findAction(content, "Add project", true);
if (input) renderedPaths.push(input.value);
return null;
}
return {
renderedPaths,
submit: () => act(() => submit!()),
submitKeyboard: () => act(() => input!.onSubmit()),
get path() {
return input?.value;
},
Expand All @@ -716,6 +722,18 @@ describe("local folder path", () => {
};
}

it("does not submit a bare folder search through the action or keyboard", async () => {
await using screen = mountLocalFolder();
await screen.render("environment");
await screen.type("project");
state.projects = [];
state.dispatch.mockReset();
await screen.submit();
await screen.submitKeyboard();
expect(state.projects).toEqual([]);
expect(state.dispatch).not.toHaveBeenCalled();
});

it("shows the new host's default path on the first render after the host changes", async () => {
await using screen = mountLocalFolder();
await screen.render("environment");
Expand Down
51 changes: 40 additions & 11 deletions apps/mobile/src/features/projects/AddProjectScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ import {
} from "@t3tools/client-runtime/state/filesystem";
import {
appendBrowsePathSegment,
ensureBrowseDirectoryPath,
inferProjectTitleFromPath,
isWindowsPlatform,
isFilesystemBrowseQuery,
} from "@t3tools/client-runtime/state/projects";
import { resolvePastedPath } from "@t3tools/shared/path";
import {
Expand Down Expand Up @@ -926,9 +928,24 @@ function FolderBrowser(props: {
readonly pinnedDirectoryName?: string;
readonly cloning?: boolean;
}) {
const browsePath = getFilesystemBrowsePath(props.pathInput, props.environment.platform);
const browsePath = getFilesystemBrowsePath(
props.pathInput,
props.environment.platform,
true,
props.cloning ? undefined : props.environment.baseDirectory || "~/",
);
const browseInput =
browsePath.directoryPath.length > 0 ? { partialPath: browsePath.directoryPath } : null;
browsePath.directoryPath.length > 0
? {
partialPath: browsePath.directoryPath,
...(!props.cloning &&
!isFilesystemBrowseQuery(props.pathInput, props.environment.platform) &&
browsePath.filterQuery &&
!browsePath.filterQuery.startsWith(".")
? { searchQuery: browsePath.filterQuery }
: {}),
}
: null;
const fileAccessSession = useEnvironmentQuery(
environmentSession.sessionStateAtom(props.environment.environmentId),
);
Expand All @@ -953,10 +970,10 @@ function FolderBrowser(props: {
? browsePath.filterQuery.toLowerCase() === pinnedDirectoryName.toLowerCase()
: browsePath.filterQuery === pinnedDirectoryName;
const browseFilterQuery = pinnedDirectoryMatches ? "" : browsePath.filterQuery;
const { visibleEntries: visibleBrowseEntries } = filterFilesystemBrowseEntries(
browseState.data?.entries ?? [],
browseFilterQuery,
);
const visibleBrowseEntries = browseState.data?.recursive
? browseState.data.entries
: filterFilesystemBrowseEntries(browseState.data?.entries ?? [], browseFilterQuery)
.visibleEntries;

return (
<>
Expand All @@ -976,8 +993,13 @@ function FolderBrowser(props: {
)}
<ListSection>
{fileAccess.isPending || (browseState.isPending && browseState.data === null) ? (
<View className="items-center py-5">
<View className="flex-row items-center justify-center gap-2 px-4 py-5">
<AppActivityIndicator colorClassName="accent-icon-muted" />
<Text className="shrink text-sm text-foreground-muted" numberOfLines={1}>
{browseInput?.searchQuery
? `Searching folders for “${browseInput.searchQuery}”…`
: "Loading folders…"}
</Text>
</View>
) : null}
{browsePath.canBrowseUp ? (
Expand Down Expand Up @@ -1018,8 +1040,10 @@ function FolderBrowser(props: {
right={null}
onPress={() => {
void props.navigateToBrowsePath({
browseDirectoryPath: browsePath.directoryPath,
selectedDirectoryName: entry.name,
browseDirectoryPath: browseState.data?.recursive
? ensureBrowseDirectoryPath(entry.fullPath)
: browsePath.directoryPath,
...(browseState.data?.recursive ? {} : { selectedDirectoryName: entry.name }),
});
}}
/>
Expand Down Expand Up @@ -1307,8 +1331,13 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);

const canSubmitPath =
canCreateProject &&
environment !== null &&
isFilesystemBrowseQuery(pathInput, environment?.platform);

const submitPath = async () => {
if (!environment || isBrowseNavigating || isSubmitting) return;
if (!environment || !canSubmitPath || isBrowseNavigating || isSubmitting) return;
setError(null);
const resolved = resolveAddProjectPath({
rawPath: pathInput,
Expand Down Expand Up @@ -1339,7 +1368,7 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st
<ProjectPathInput value={pathInput} onChangeText={setPathInput} onSubmit={submitPath} />
<PrimaryActionButton
label="Add project"
disabled={!canCreateProject || isBrowseNavigating || isSubmitting}
disabled={!canSubmitPath || isBrowseNavigating || isSubmitting}
onPress={submitPath}
loading={isSubmitting}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,19 @@ import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

type DirectoryInput = {
accessibilityLabel: string;
onChangeText?: (text: string) => void;
onFocus?: () => void;
onEndEditing: (event: { nativeEvent: { text: string } }) => void;
};

const { Children, mocks } = vi.hoisted(() => ({
Children: ({ children }: { children?: ReactNode }) => children,
mocks: {
branchNaming: { onChange: (_patch: ServerSettingsPatch) => {} },
updateSettings: vi.fn(),
inputs: new Map<string, DirectoryInput>(),
alert: vi.fn(),
targets: [] as ReturnType<typeof environment>[],
readOnly: new Set<string>(),
Expand Down Expand Up @@ -69,7 +77,13 @@ vi.mock("../threads/thread-settings-options", () => ({
withManagedRuntimeModeLimits: (choices: readonly unknown[]) => choices,
}));
vi.mock("../../components/ui/ScreenScrollView", () => ({ ScreenScrollView: Children }));
vi.mock("../../components/ui/AppText", () => ({ AppText: Children }));
vi.mock("../../components/ui/AppText", () => ({
AppText: Children,
AppTextInput: (props: DirectoryInput) => {
mocks.inputs.set(props.accessibilityLabel, props);
return null;
},
}));
vi.mock("../../components/ui/Button", () => ({
Button: ({
label,
Expand Down Expand Up @@ -174,6 +188,7 @@ beforeEach(() => {
mocks.targets = [environment("local", "Local")];
mocks.projectSelected = false;
mocks.readOnly = new Set();
mocks.inputs.clear();
mocks.alert.mockReset();
mocks.updateSettings.mockReset();
mocks.updateSettings.mockResolvedValue(AsyncResult.success(undefined));
Expand All @@ -184,6 +199,29 @@ afterEach(() => {
});

describe("SettingsServerControlsRouteScreen", () => {
it("leaves mixed directories untouched on blur and saves an intentional empty edit", async () => {
const local = environment("local", "Local");
const remote = environment("remote", "Remote");
local.serverConfig.settings.projectSearchDirectories = ["/work"];
remote.serverConfig.settings.projectSearchDirectories = ["/other"];
mocks.targets = [local, remote];
act(() => root.render(<SettingsThreadsRouteScreen />));
const input = mocks.inputs.get("Project search folders")!;
act(() => input.onFocus?.());
await act(() => input.onEndEditing({ nativeEvent: { text: "" } }));
expect(mocks.updateSettings).not.toHaveBeenCalled();

act(() => {
input.onChangeText!("/temporary");
input.onChangeText!("");
});
await act(() => input.onEndEditing({ nativeEvent: { text: "" } }));
expect(mocks.updateSettings.mock.calls).toEqual([
[{ environmentId: "local", input: { patch: { projectSearchDirectories: [] } } }],
[{ environmentId: "remote", input: { patch: { projectSearchDirectories: [] } } }],
]);
});

it("sends a draft saved during a pending write once that write settles", async () => {
const pending = Promise.withResolvers<AsyncResult.Success<void>>();
mocks.updateSettings.mockReturnValueOnce(pending.promise);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { SettingsRow } from "./components/SettingsRow";
import { AuthSettingsWriteScope } from "@t3tools/contracts";
import { readEnvironmentScope, useEnvironmentsWithScope } from "../../state/session";
import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView";
import { AppText as Text } from "../../components/ui/AppText";
import { AppText as Text, AppTextInput } from "../../components/ui/AppText";
import {
type ResponseStreamingMode,
type ServerSettings,
Expand Down Expand Up @@ -38,6 +38,7 @@ import {
planMobileScopedSettingsPatch,
resolveMobileSettingsTargets,
uniformMobileSetting,
mobileSettingValuesEqual,
type ScopedMobileSettingsTarget,
} from "./settings-scoped-server";
import { PullRequestGlyph } from "../pull-requests/pullRequestGlyphs";
Expand Down Expand Up @@ -187,6 +188,7 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
);
const [pendingWrites, setPendingWrites] = useState(0);
const writeInFlight = useRef(false);
const editedDirectoryInputs = useRef(new Set<string>());
// Closing views can save after controls disable; queue those patches behind the pending write.
const queuedPatch = useRef<{ key: string; patch: ServerSettingsPatch } | null>(null);
const targetsKey = targets
Expand All @@ -206,7 +208,9 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
// `uniform` folds a real null into "mixed"; nullable keys need the distinction.
const isMixed = (key: keyof ServerSettings) =>
reference === null ||
displayTargets.some((entry) => entry.settings[key] !== reference.settings[key]);
displayTargets.some(
(entry) => !mobileSettingValuesEqual(entry.settings[key], reference.settings[key]),
);
const writeSettings = useSettingsWriter();
const write = (patch: ServerSettingsPatch) => {
if (writeInFlight.current) {
Expand Down Expand Up @@ -398,6 +402,71 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
</>
) : null}

{props.page === "threads" ? (
<SettingsSection title="New projects">
{(["projectSearchDirectories", "addProjectBaseDirectory"] as const).map((key) => {
const saved = uniform(key);
const value = Array.isArray(saved)
? saved.join("; ")
: typeof saved === "string"
? saved
: "";
const inputKey = `${targetsKey}:${key}:${value}`;
const label =
key === "projectSearchDirectories"
? "Project search folders"
: "Add project starts in";
return (
<View key={key} className="gap-2 px-4 py-3">
<Text>{label}</Text>
<Text className="text-sm text-foreground-muted">
{key === "projectSearchDirectories"
? "Search nested folders. Separate base paths with semicolons."
: "Leave empty to start in your home folder."}
</Text>
<AppTextInput
key={inputKey}
accessibilityLabel={label}
defaultValue={value}
placeholder={
isMixed(key)
? "Mixed"
: key === "projectSearchDirectories"
? "~/projects; ~/work"
: "Default folder"
}
editable={!disabledFor(key)}
autoCapitalize="none"
autoCorrect={false}
className="min-h-10 rounded-xl px-3 py-2 text-base text-foreground"
onFocus={() => editedDirectoryInputs.current.delete(inputKey)}
onChangeText={() => editedDirectoryInputs.current.add(inputKey)}
onEndEditing={(event) => {
if (!editedDirectoryInputs.current.delete(inputKey)) return;
const text = event.nativeEvent.text.trim();
if (text === value && !isMixed(key)) return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
write(
key === "projectSearchDirectories"
? {
projectSearchDirectories: [
...new Set(
text
.split(";")
.map((path) => path.trim())
.filter(Boolean),
),
].slice(0, 32),
}
: { [key]: text },
);
}}
/>
</View>
);
})}
</SettingsSection>
) : null}

{props.page === "source-control" ? (
<>
{projectSelected ? null : (
Expand Down
15 changes: 15 additions & 0 deletions apps/mobile/src/features/settings/settings-scoped-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,18 @@ describe("managed settings across environments", () => {
expect(managedSettingReason(targets.slice(0, 1), "storageCleanup")).toBeUndefined();
});
});

it("shows equal search-folder arrays as uniform and allows clearing them", () => {
const targets = resolveMobileSettingsTargets(
[
environment(firstId, { ...DEFAULT_SERVER_SETTINGS, projectSearchDirectories: ["/work"] }),
environment(secondId, { ...DEFAULT_SERVER_SETTINGS, projectSearchDirectories: ["/work"] }),
],
null,
);
expect(uniformMobileSetting(targets, "projectSearchDirectories")).toEqual(["/work"]);
expect(planMobileScopedSettingsPatch(targets, false, { projectSearchDirectories: [] })).toEqual([
{ environmentId: firstId, patch: { projectSearchDirectories: [] } },
{ environmentId: secondId, patch: { projectSearchDirectories: [] } },
]);
});
Loading
Loading