Repository navigation
feat(projects): choose where New project creates repositories #32480
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
970f2b1
f6d3b1c
7bfd1cf
c5a1783
67e8783
77cc099
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -13,7 +13,7 @@ import { | |
| PROJECT_SCOPED_SERVER_SETTING_KEYS, | ||
| type ProjectScopedServerSettingKey, | ||
| } from "@t3tools/contracts"; | ||
| import { useRef, useState } from "react"; | ||
| import { useEffect, useEffectEvent, useRef, useState } from "react"; | ||
| import { View } from "react-native"; | ||
| import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
|
|
||
|
|
@@ -209,6 +209,10 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { | |
| const isMixed = (key: keyof ServerSettings) => | ||
| reference === null || | ||
| displayTargets.some((entry) => entry.settings[key] !== reference.settings[key]); | ||
| const uniformDirectory = (key: "newProjectDirectory" | "addProjectBaseDirectory") => { | ||
| const saved = uniform(key); | ||
| return typeof saved === "string" ? saved : ""; | ||
| }; | ||
| const writeSettings = useSettingsWriter(); | ||
| const write = (patch: ServerSettingsPatch) => { | ||
| if (writeInFlight.current) { | ||
|
|
@@ -242,6 +246,26 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { | |
| if (queued?.key === targetsKey) write({ ...patch, ...queued.patch }); | ||
| }); | ||
| }; | ||
| // Save on exit too: Android Back hides the keyboard without ending editing. | ||
| type DirectoryKey = "newProjectDirectory" | "addProjectBaseDirectory"; | ||
| const directoryDrafts = useRef<Partial<Record<DirectoryKey, { text: string; over: string }>>>({}); | ||
| const takeDirectoryDraft = (key: DirectoryKey): ServerSettingsPatch => { | ||
| const draft = directoryDrafts.current[key]; | ||
| delete directoryDrafts.current[key]; | ||
| // A draft typed over an older saved value is stale: the new value already remounted its input. | ||
| if (draft === undefined || draft.over !== uniformDirectory(key)) return {}; | ||
| const text = draft.text.trim(); | ||
| return text === draft.over ? {} : { [key]: text }; | ||
| }; | ||
| const saveDirectoryDrafts = useEffectEvent(() => { | ||
| const patch = { | ||
| ...takeDirectoryDraft("newProjectDirectory"), | ||
| ...takeDirectoryDraft("addProjectBaseDirectory"), | ||
| }; | ||
| if (Object.keys(patch).length > 0) write(patch); | ||
| }); | ||
| // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- saves unsent directory drafts to server settings when the screen unmounts | ||
| useEffect(() => () => saveDirectoryDrafts(), []); | ||
| const clearProjectOverrides = () => { | ||
| if ( | ||
| writeInFlight.current || | ||
|
|
@@ -400,6 +424,47 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { | |
| </> | ||
| ) : null} | ||
|
|
||
| {props.page === "threads" ? ( | ||
| <SettingsSection title="New projects"> | ||
| {(["newProjectDirectory", "addProjectBaseDirectory"] as const).map((key) => { | ||
| const value = uniformDirectory(key); | ||
| const label = | ||
| key === "newProjectDirectory" | ||
| ? "New project location" | ||
| : "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 === "newProjectDirectory" | ||
| ? "Leave empty to use T3 home's projects folder." | ||
| : "Leave empty to start in your home folder."} | ||
| </Text> | ||
| <AppTextInput | ||
| key={`${targetsKey}:${value}`} | ||
| accessibilityLabel={label} | ||
| defaultValue={value} | ||
| placeholder={isMixed(key) ? "Mixed" : "Default folder"} | ||
| editable={!disabledFor(key)} | ||
| autoCapitalize="none" | ||
| autoCorrect={false} | ||
| className="min-h-10 rounded-xl px-3 py-2 text-base text-foreground" | ||
| onChangeText={(text) => { | ||
| directoryDrafts.current[key] = { text, over: value }; | ||
| }} | ||
| onEndEditing={(event) => { | ||
| delete directoryDrafts.current[key]; | ||
| const text = event.nativeEvent.text.trim(); | ||
| if (text === value) return; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Distinguish mixed values from a saved empty directory. When selected environments use Retain the mixed-state distinction so an edited blank input resets all selected environments. Cover this case in a focused mobile settings regression.
|
||
| write({ [key]: text }); | ||
| }} | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
| /> | ||
| </View> | ||
| ); | ||
| })} | ||
| </SettingsSection> | ||
| ) : null} | ||
|
|
||
| {props.page === "source-control" ? ( | ||
| <> | ||
| {projectSelected ? null : ( | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts"; | ||
| import { expect, it, vi } from "vite-plus/test"; | ||
|
|
||
| import { render } from "../../../test/render"; | ||
|
|
||
| const state = vi.hoisted(() => ({ mixedDirectory: false })); | ||
| vi.mock(import("./useScopedSettings"), async (original) => ({ | ||
| ...(await original()), | ||
| useScopedSettings: (() => DEFAULT_UNIFIED_SETTINGS) as never, | ||
| useScopedSettingsMixed: (keys) => keys.includes("newProjectDirectory") && state.mixedDirectory, | ||
| useUpdateScopedSettings: () => vi.fn(), | ||
| })); | ||
|
|
||
| import { useSettingsRestore } from "./SettingsPanels"; | ||
|
|
||
| function RestoreSummary() { | ||
| const { changedSettingLabels } = useSettingsRestore(); | ||
| return <output>{changedSettingLabels.join(", ")}</output>; | ||
| } | ||
|
|
||
| it("includes a custom project location on another environment in restore confirmation", async () => { | ||
| state.mixedDirectory = false; | ||
| await using screen = await render(<RestoreSummary />); | ||
| await expect.element(screen.getByRole("status")).not.toHaveTextContent("New project location"); | ||
| state.mixedDirectory = true; | ||
| await screen.rerender(<RestoreSummary />); | ||
| await expect.element(screen.getByRole("status")).toHaveTextContent("New project location"); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do not save a directory draft to a different environment selection.
directoryDraftsstores each draft with only its text and saved value. When the environment filter changes, the input remounts, but its unsent draft remains in the ref. At screen exit,saveDirectoryDraftsuses the currenttargets. If the old and new environments both have an empty saved value, the draft passes theovercheck and can be written to the newly selected environment.Record the target with each draft. Discard the draft when its target no longer matches the current selection.
Suggested fix
Committable suggestion