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
51 changes: 51 additions & 0 deletions apps/desktop/src/electron/ElectronDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ export class ElectronDialogPickApplicationError extends Schema.TaggedErrorClass<
}
}

export class ElectronDialogPickFilesError extends Schema.TaggedErrorClass<ElectronDialogPickFilesError>()(
"ElectronDialogPickFilesError",
{
ownerWindowId: Schema.NullOr(Schema.Number),
defaultPath: Schema.NullOr(Schema.String),
cause: Schema.Defect(),
},
) {
override get message(): string {
const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`;
const defaultPath = this.defaultPath === null ? "no default path" : this.defaultPath;
return `Failed to open the Electron file picker for ${owner} with ${defaultPath}.`;
}
}

export class ElectronDialogConfirmError extends Schema.TaggedErrorClass<ElectronDialogConfirmError>()(
"ElectronDialogConfirmError",
{
Expand Down Expand Up @@ -88,6 +103,7 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass<Ele
export const ElectronDialogError = Schema.Union([
ElectronDialogPickFolderError,
ElectronDialogPickApplicationError,
ElectronDialogPickFilesError,
ElectronDialogConfirmError,
ElectronDialogShowMessageBoxError,
ElectronDialogShowErrorBoxError,
Expand All @@ -104,6 +120,12 @@ export interface ElectronDialogPickApplicationInput {
readonly owner: Option.Option<Electron.BrowserWindow>;
}

export interface ElectronDialogPickFilesInput {
readonly owner: Option.Option<Electron.BrowserWindow>;
readonly defaultPath: Option.Option<string>;
readonly filters: readonly Electron.FileFilter[];
}

export interface ElectronDialogConfirmInput {
readonly owner: Option.Option<Electron.BrowserWindow>;
readonly message: string;
Expand All @@ -121,6 +143,9 @@ export class ElectronDialog extends Context.Service<
Option.Option<DesktopApplicationSelection>,
ElectronDialogPickApplicationError
>;
readonly pickFiles: (
input: ElectronDialogPickFilesInput,
) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>;
readonly confirm: (
input: ElectronDialogConfirmInput,
) => Effect.Effect<boolean, ElectronDialogConfirmError>;
Expand Down Expand Up @@ -169,6 +194,32 @@ export const make = Effect.gen(function* () {
}
return Option.fromNullishOr(result.filePaths[0]);
}),
pickFiles: Effect.fn("desktop.electron.dialog.pickFiles")(function* (input) {
const ownerWindowId = Option.match(input.owner, {
onNone: () => null,
onSome: (owner) => owner.id,
});
const defaultPath = Option.getOrNull(input.defaultPath);
const openDialogOptions: Electron.OpenDialogOptions = {
properties: ["openFile", "multiSelections"],
filters: [...input.filters],
...(defaultPath === null ? {} : { defaultPath }),
};
const result = yield* Effect.tryPromise({
try: () =>
Option.match(input.owner, {
onNone: () => Electron.dialog.showOpenDialog(openDialogOptions),
onSome: (owner) => Electron.dialog.showOpenDialog(owner, openDialogOptions),
}),
catch: (cause) =>
new ElectronDialogPickFilesError({
ownerWindowId,
defaultPath,
cause,
}),
});
return result.canceled ? [] : result.filePaths;
}),
pickApplication: Effect.fn("desktop.electron.dialog.pickApplication")(function* (input) {
const ownerWindowId = Option.match(input.owner, {
onNone: () => null,
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
getWindowFullscreenState,
openExternal,
pickFolder,
pickThemeFiles,
setTheme,
showContextMenu,
} from "./methods/window.ts";
Expand Down Expand Up @@ -84,6 +85,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(setWslOnly);

yield* ipc.handle(pickFolder);
yield* ipc.handle(pickThemeFiles);
yield* ipc.handle(confirm);
yield* ipc.handle(setTheme);
yield* ipc.handle(showContextMenu);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
export const CONFIRM_CHANNEL = "desktop:confirm";
export const SET_THEME_CHANNEL = "desktop:set-theme";
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
Expand Down
51 changes: 51 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ import {
DesktopAppBrandingSchema,
DesktopEnvironmentBootstrapSchema,
DesktopThemeSchema,
PickedThemeFileSchema,
PickFolderOptionsSchema,
PRIMARY_LOCAL_ENVIRONMENT_ID,
type DesktopEnvironmentBootstrap,
type PickedThemeFile,
} from "@t3tools/contracts";
import * as NodeOS from "node:os";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
Expand Down Expand Up @@ -268,3 +273,49 @@ export const openExternal = DesktopIpc.makeIpcMethod({
return yield* shell.openExternal(url);
}),
});

/** Theme files are a few KB; anything larger returns empty text and lets the
* renderer reject it by size without the contents ever crossing the bridge. */
const PICKED_THEME_FILE_MAX_BYTES = 256 * 1024;

export const pickThemeFiles = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PICK_THEME_FILES_CHANNEL,
payload: Schema.Undefined,
result: Schema.NullOr(Schema.Array(PickedThemeFileSchema)),
handler: Effect.fn("desktop.ipc.window.pickThemeFiles")(function* () {
const dialog = yield* ElectronDialog.ElectronDialog;
const electronWindow = yield* ElectronWindow.ElectronWindow;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
// The VS Code extensions directory is the same dotfolder on Windows,
// macOS, and Linux; when it is missing the picker opens wherever the
// platform would by default.
const extensionsDir = path.join(NodeOS.homedir(), ".vscode", "extensions");
const defaultPath = yield* fileSystem
.exists(extensionsDir)
.pipe(Effect.orElseSucceed(() => false));
const paths = yield* dialog.pickFiles({
owner: yield* electronWindow.focusedMainOrFirst,
defaultPath: defaultPath ? Option.some(extensionsDir) : Option.none(),
filters: [{ name: "JSON", extensions: ["json"] }],
});
if (paths.length === 0) {
return null;
}
return yield* Effect.forEach(paths, (filePath) => {
const name = path.basename(filePath);
return Effect.gen(function* () {
const info = yield* fileSystem.stat(filePath);
const size = Number(info.size);
if (size > PICKED_THEME_FILE_MAX_BYTES) {
return { name, size, text: "" } satisfies PickedThemeFile;
}
const text = yield* fileSystem.readFileString(filePath);
return { name, size, text } satisfies PickedThemeFile;
}).pipe(
// An unreadable file degrades to an entry the renderer reports.
Effect.orElseSucceed((): PickedThemeFile => ({ name, size: 0, text: "" })),
);
});
}),
});
1 change: 1 addition & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
setWslDistro: (distro) => ipcRenderer.invoke(IpcChannels.SET_WSL_DISTRO_CHANNEL, distro),
setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled),
pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options),
pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined),
confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message),
setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
showContextMenu: (items, position) =>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, {
pickFolder: () => Effect.succeed(Option.none()),
pickApplication: () => Effect.succeed(Option.none()),
pickFiles: () => Effect.succeed([]),
confirm: () => Effect.succeed(false),
showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }),
showErrorBox: () => Effect.void,
Expand Down
2 changes: 1 addition & 1 deletion apps/marketing/src/pages/download.astro
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ import { ANDROID_PLAY_STORE_URL, IOS_APP_STORE_URL } from "../lib/site";
</div>

<p class="releases-link">
Looking for older versions? Check the
Looking for older versions? Check the{" "}
<a href="https://github.com/pingdotgg/t3code/releases" target="_blank" rel="noopener noreferrer">
GitHub releases page<span aria-hidden="true"> &#8599;</span>
</a>
Expand Down
71 changes: 47 additions & 24 deletions apps/mobile/src/features/connection/CloudEnvironmentRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { useThemeColor } from "../../lib/useThemeColor";
import type { ConnectedEnvironmentSummary } from "../../state/remote-runtime-types";
import { availableCloudEnvironmentPresentation } from "../cloud/cloudEnvironmentPresentation";
import { hasCloudPublicConfig } from "../cloud/publicConfig";
import { ConnectionStatusDot } from "./ConnectionStatusDot";
import { type RelayEnvironmentView, useConnectionController } from "./useConnectionController";

Expand All @@ -42,6 +43,11 @@ interface CloudEnvironmentRowsProps {
* with connect switches, availability status, refresh, and loading/error
* states. Shared between the Settings environments screen and the T3 Connect
* onboarding sheet.
*
* Already-connected relay environments render even without cloud config or a
* signed-in account — they are registered on this device and must stay
* reachable and removable. Only discovery (the available list, refresh, and
* its errors) requires a signed-in session.
*/
export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) {
// Showcase captures run without a Clerk publishable key, so `ClerkProvider`
Expand All @@ -50,20 +56,33 @@ export function CloudEnvironmentRows(props: CloudEnvironmentRowsProps) {
if (props.showcaseSignedIn !== undefined) {
return props.showcaseSignedIn ? <CloudEnvironmentRowsContent {...props} /> : null;
}
// No cloud config means no `ClerkProvider` either, so `useAuth` would throw.
if (!hasCloudPublicConfig()) {
return <ConnectedOnlyCloudEnvironmentRows {...props} />;
}
return <SignedInCloudEnvironmentRows {...props} />;
}

function SignedInCloudEnvironmentRows(props: CloudEnvironmentRowsProps) {
const { isSignedIn } = useAuth({ treatPendingAsSignedOut: false });
if (!isSignedIn) return null;
if (!isSignedIn) return <ConnectedOnlyCloudEnvironmentRows {...props} />;
return <CloudEnvironmentRowsContent {...props} />;
}

function CloudEnvironmentRowsContent(props: CloudEnvironmentRowsProps) {
function ConnectedOnlyCloudEnvironmentRows(props: CloudEnvironmentRowsProps) {
if (props.connectedCloudEnvironments.length === 0) return null;
return <CloudEnvironmentRowsContent {...props} discoveryAvailable={false} />;
}

function CloudEnvironmentRowsContent(
props: CloudEnvironmentRowsProps & { readonly discoveryAvailable?: boolean },
) {
const controller = useConnectionController();
const iconColor = useThemeColor("--color-icon");
const availableCloudEnvironments =
props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments;
const discoveryAvailable = props.discoveryAvailable ?? true;
const availableCloudEnvironments = discoveryAvailable
? (props.showcaseAvailableEnvironments ?? controller.availableRelayEnvironments)
: [];
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
const hasCloudRows =
props.connectedCloudEnvironments.length > 0 || availableCloudEnvironments.length > 0;
Expand All @@ -89,25 +108,27 @@ function CloudEnvironmentRowsContent(props: CloudEnvironmentRowsProps) {
{showHeader ? (
<View className="flex-row items-center justify-between px-1">
<Text className="text-sm font-t3-bold uppercase text-foreground-muted">T3 Connect</Text>
<Pressable
accessibilityRole="button"
disabled={controller.relayDiscovery.isRefreshing}
onPress={() => {
void controller.refreshRelayEnvironments();
}}
className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50"
>
{controller.relayDiscovery.isRefreshing ? (
<ActivityIndicator color={iconColor} size="small" />
) : (
<SymbolView
name="arrow.clockwise"
size={14}
tintColor={iconColor}
type="monochrome"
/>
)}
</Pressable>
{discoveryAvailable ? (
<Pressable
accessibilityRole="button"
disabled={controller.relayDiscovery.isRefreshing}
onPress={() => {
void controller.refreshRelayEnvironments();
}}
className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50"
>
{controller.relayDiscovery.isRefreshing ? (
<ActivityIndicator color={iconColor} size="small" />
) : (
<SymbolView
name="arrow.clockwise"
size={14}
tintColor={iconColor}
type="monochrome"
/>
)}
</Pressable>
) : null}
</View>
) : null}

Expand Down Expand Up @@ -152,7 +173,9 @@ function CloudEnvironmentRowsContent(props: CloudEnvironmentRowsProps) {

{/* Rendered alongside any connected rows — a failed discovery must not
hide behind an otherwise-healthy list. */}
{controller.relayDiscovery.error && !controller.relayDiscovery.isRefreshing ? (
{discoveryAvailable &&
controller.relayDiscovery.error &&
!controller.relayDiscovery.isRefreshing ? (
<View collapsable={false} className="gap-3 rounded-[24px] bg-card p-5">
<Text className="text-base font-t3-bold text-foreground">
Could not load T3 Connect environments
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";

import { AppText as Text } from "../../components/AppText";
import { AndroidScreenHeader } from "../../components/AndroidScreenHeader";
import { hasCloudPublicConfig } from "../cloud/publicConfig";
import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows";
import { ConnectionEnvironmentRow } from "../connection/ConnectionEnvironmentRow";
import { splitEnvironmentSections } from "../connection/environmentSections";
Expand Down Expand Up @@ -161,18 +160,19 @@ export function SettingsEnvironmentsRouteScreen() {
</View>
)}

{hasCloudPublicConfig() || SHOWCASE_ENABLED ? (
<CloudEnvironmentRows
connectedCloudEnvironments={connectedCloudEnvironments}
onReconnectEnvironment={onReconnectEnvironment}
{...(SHOWCASE_ENABLED
? {
showcaseAvailableEnvironments: SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS,
showcaseSignedIn: true,
}
: {})}
/>
) : null}
{/* Always mounted: already-connected relay environments must stay
visible (and removable) even when cloud config is missing or the
user is signed out — the component gates discovery itself. */}
<CloudEnvironmentRows
connectedCloudEnvironments={connectedCloudEnvironments}
onReconnectEnvironment={onReconnectEnvironment}
{...(SHOWCASE_ENABLED
? {
showcaseAvailableEnvironments: SHOWCASE_AVAILABLE_CLOUD_ENVIRONMENTS,
showcaseSignedIn: true,
}
: {})}
/>
</ScrollView>
</View>
);
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9");
assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m");
assert.equal(defaultsByCommand.get("board.open"), "mod+t");
assert.equal(defaultsByCommand.get("themeEditor.toggle"), "mod+alt+shift+t");
assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p");
assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f");
assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b");
assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b");
assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d");
Expand Down
Loading
Loading