Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
87bff90
feat(composer): fold large pastes into attachments
chrisdeeming Sep 8, 2026
442fa25
fix(mobile): preserve paste fallback without uploads
chrisdeeming Sep 8, 2026
c3a17ee
fix(mobile): repair paste-fold merge on native bridges
chrisdeeming Sep 11, 2026
c751ae9
fix(mobile): keep non-media attachments out of the composer strip
chrisdeeming Sep 12, 2026
15d8f17
fix(mobile): make composer chips inert while the composer rests
chrisdeeming Sep 12, 2026
dacbba3
fix(mobile): give a folded paste its chip
chrisdeeming Sep 12, 2026
8677947
feat: wrap text previews by default, and toggle wrapping where they r…
chrisdeeming Sep 12, 2026
d1a4038
fix(mobile): actually wrap source text, and show the wrap toggle for …
chrisdeeming Sep 12, 2026
0dd4660
fix(mobile): make source text selectable across lines, and offer Copy…
chrisdeeming Sep 12, 2026
a44ae42
fix(mobile): select source text through a real text view, not a line …
chrisdeeming Sep 12, 2026
c972223
fix(mobile): preserve attachment selection in both wrap modes
chrisdeeming Sep 12, 2026
3ef40ab
fix(composer): preserve paste context and recover file drafts
chrisdeeming Sep 12, 2026
4a56d33
fix(composer): preserve paste targets and attachment preview behavior
chrisdeeming Sep 12, 2026
4d28ab2
fix(web): open pull requests directly from context chips
chrisdeeming Sep 12, 2026
cf828ce
fix(chat): preserve inline review chip spacing on older servers
chrisdeeming Sep 12, 2026
34a2a8c
style(mobile): satisfy native paste callback lint rules
chrisdeeming Sep 12, 2026
313aef6
fix(mobile): replace selected context and normalize selection ranges
chrisdeeming Sep 12, 2026
bb991bc
fix(mobile): release document-picked images when their chips are removed
chrisdeeming Sep 12, 2026
91bfcc9
fix(mobile): preserve native paste and file viewer defaults
chrisdeeming Sep 12, 2026
b6b9242
fix(mobile): check Android paste-as-text input limits
chrisdeeming Sep 12, 2026
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
2 changes: 2 additions & 0 deletions apps/desktop/src/ipc/DesktopIpcHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
openExternal,
openSystemSettings,
checkSystemPermission,
pasteAsText,
probeRemoteEditors,
pickFolder,
pickProjectFavicon,
Expand Down Expand Up @@ -124,6 +125,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
yield* ipc.handle(openExternal);
yield* ipc.handle(openSystemSettings);
yield* ipc.handle(checkSystemPermission);
yield* ipc.handle(pasteAsText);
yield* ipc.handle(probeRemoteEditors);
yield* ipc.handle(getUpdateState);
yield* ipc.handle(setUpdateChannel);
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings";
export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
export const MENU_ACTION_CHANNEL = "desktop:menu-action";
export const PASTE_AS_TEXT_CHANNEL = "desktop:paste-as-text";
export const SNAP_SHOT_EVENT_CHANNEL = "desktop:snap-shot-event";
export const QUIT_SHORTCUT_CHANNEL = "desktop:quit-shortcut";
export const GET_WINDOW_FULLSCREEN_STATE_CHANNEL = "desktop:get-window-fullscreen-state";
Expand Down
54 changes: 54 additions & 0 deletions apps/desktop/src/ipc/methods/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,23 @@ import { vi } from "vite-plus/test";

import type * as Electron from "electron";

const { focusedWebContents, ownerWindow } = vi.hoisted(() => ({
focusedWebContents: vi.fn(),
ownerWindow: vi.fn(),
}));
vi.mock("electron", () => ({
webContents: { getFocusedWebContents: focusedWebContents },
BrowserWindow: { fromWebContents: ownerWindow },
}));

import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts";
import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts";
import * as ElectronDialog from "../../electron/ElectronDialog.ts";
import * as ElectronWindow from "../../electron/ElectronWindow.ts";
import {
getLocalEnvironmentBootstraps,
getWindowFullscreenState,
pasteAsText,
pickProjectFavicon,
} from "./window.ts";

Expand Down Expand Up @@ -153,6 +163,50 @@ describe("getWindowFullscreenState", () => {
});
});

describe("pasteAsText", () => {
it.effect(
"pastes into the focused guest only after the main renderer acknowledges the menu action",
() => {
const paste = vi.fn();
const mainPaste = vi.fn();
const window = {
webContents: { id: 42, paste: mainPaste },
isDestroyed: () => false,
} as unknown as Electron.BrowserWindow;
focusedWebContents.mockReturnValue({ paste, isDestroyed: () => false });
ownerWindow.mockReturnValue(window);

return Effect.gen(function* () {
yield* pasteAsText.handler(undefined, { sender: { id: 42 } });
assert.equal(paste.mock.calls.length, 1);
assert.equal(mainPaste.mock.calls.length, 0);

yield* pasteAsText.handler(undefined, { sender: { id: 99 } });
assert.equal(paste.mock.calls.length, 1);
ownerWindow.mockReturnValue({}); // A focused PiP/other BrowserWindow.
yield* pasteAsText.handler(undefined, { sender: { id: 42 } });
assert.equal(paste.mock.calls.length, 1);
ownerWindow.mockReturnValue(null); // Detached contents.
yield* pasteAsText.handler(undefined, { sender: { id: 42 } });
assert.equal(paste.mock.calls.length, 1);
ownerWindow.mockReturnValue(window);
focusedWebContents.mockReturnValue({ paste, isDestroyed: () => true });
yield* pasteAsText.handler(undefined, { sender: { id: 42 } });
assert.equal(paste.mock.calls.length, 1);
focusedWebContents.mockReturnValue(null);
yield* pasteAsText.handler(undefined, { sender: { id: 42 } });
assert.equal(paste.mock.calls.length, 1);
}).pipe(
Effect.provide(
Layer.mock(ElectronWindow.ElectronWindow)({
main: Effect.succeed(Option.some(window)),
}),
),
);
},
);
});

describe("pickProjectFavicon", () => {
it.effect("opens a single-image picker from the project directory", () =>
Effect.gen(function* () {
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/ipc/methods/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,32 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({
}),
});

export const pasteAsText = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PASTE_AS_TEXT_CHANNEL,
payload: Schema.Undefined,
result: Schema.Void,
handler: Effect.fn("desktop.ipc.window.pasteAsText")(function* (_input, event) {
const electronWindow = yield* ElectronWindow.ElectronWindow;
const window = yield* electronWindow.main;
if (
event === undefined ||
Option.isNone(window) ||
window.value.isDestroyed() ||
window.value.webContents.id !== event.sender.id
) {
return;
}
const focused = Electron.webContents.getFocusedWebContents();
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
if (
focused &&
!focused.isDestroyed() &&
Electron.BrowserWindow.fromWebContents(focused) === window.value
) {
focused.paste();
}
}),
});

/** 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;
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ contextBridge.exposeInMainWorld("desktopBridge", {
openSystemSettings: (pane: string) =>
ipcRenderer.invoke(IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL, pane),
probeRemoteEditors: () => ipcRenderer.invoke(IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, undefined),
pasteAsText: () => ipcRenderer.invoke(IpcChannels.PASTE_AS_TEXT_CHANNEL, undefined),
onMenuAction: (listener) => {
const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
if (typeof action !== "string") return;
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop/src/window/DesktopApplicationMenu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,36 @@ describe("DesktopApplicationMenu", () => {
}),
);

it.effect("owns Paste as Text and routes it through the renderer", () =>
Effect.gen(function* () {
const selectedAction = yield* Deferred.make<string>();
const applicationMenuTemplate =
yield* Deferred.make<readonly Electron.MenuItemConstructorOptions[]>();

yield* configureMenu(selectedAction, applicationMenuTemplate);

const template = yield* Deferred.await(applicationMenuTemplate);
const editMenu = template.find((item) => item.label === "Edit");
assert.isDefined(editMenu);
if (!Array.isArray(editMenu.submenu)) {
throw new Error("Expected Edit menu submenu to be an array.");
}
const pasteAsTextItem = editMenu.submenu.find((item) => item.label === "Paste as Text");
assert.isDefined(pasteAsTextItem);
assert.equal(pasteAsTextItem.accelerator, "CmdOrCtrl+Shift+V");
if (typeof pasteAsTextItem.click !== "function") {
throw new Error("Expected Paste as Text menu item to have a click handler.");
}

pasteAsTextItem.click(
{} as Electron.MenuItem,
{} as Electron.BrowserWindow,
{} as KeyboardEvent,
);
assert.equal(yield* Deferred.await(selectedAction), "paste-as-text");
}),
);

// Zoom must route through DesktopWindow.zoomMain instead of the Electron
// zoom roles: the roles zoom whichever webContents has focus, which breaks
// app zoom while an embedded preview WebContentsView holds focus.
Expand Down
36 changes: 34 additions & 2 deletions apps/desktop/src/window/DesktopApplicationMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ const dispatchMenuAction = Effect.fn("desktop.menu.dispatchMenuAction")(function
action: string,
): Effect.fn.Return<void, DesktopWindow.DesktopWindowError, DesktopWindow.DesktopWindow> {
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.dispatchMenuAction(action);
yield* desktopWindow.dispatchMenuAction(action, {
reveal: action !== "paste-as-text",
});
});

const zoomMainWindow = Effect.fn("desktop.menu.zoomMainWindow")(function* (
Expand Down Expand Up @@ -135,6 +137,9 @@ export const make = Effect.gen(function* () {
const settingsClick = () => {
runMenuEffect("open-settings", dispatchMenuAction("open-settings"));
};
const pasteAsTextClick = () => {
runMenuEffect("paste-as-text", dispatchMenuAction("paste-as-text"));
};
const zoomClick = (direction: DesktopWindow.MainWindowZoomDirection) => () => {
runMenuEffect(`zoom-${direction}`, zoomMainWindow(direction));
};
Expand Down Expand Up @@ -184,7 +189,34 @@ export const make = Effect.gen(function* () {
{ role: environment.platform === "darwin" ? "close" : "quit" },
],
},
{ role: "editMenu" },
{
label: "Edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
{ type: "separator" },
{ role: "cut" },
{ role: "copy" },
{ role: "paste" },
{
label: "Paste as Text",
accelerator: "CmdOrCtrl+Shift+V",
click: pasteAsTextClick,
},
{ role: "delete" },
{ type: "separator" },
{ role: "selectAll" },
...(environment.platform === "darwin"
? [
{ type: "separator" as const },
{
label: "Speech",
submenu: [{ role: "startSpeaking" as const }, { role: "stopSpeaking" as const }],
},
]
: []),
],
},
{
label: "View",
submenu: [
Expand Down
12 changes: 12 additions & 0 deletions apps/mobile/modules/t3-composer-editor/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ android {
namespace 'expo.modules.t3composereditor'
compileSdk rootProject.ext.compileSdkVersion

testOptions {
unitTests.includeAndroidResources = true
}

defaultConfig {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
Expand All @@ -17,4 +21,12 @@ android {
dependencies {
implementation project(':expo-modules-core')
implementation project(':t3tools-mobile-markdown-text')
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.robolectric:robolectric:4.16.1'
}

tasks.withType(Test).configureEach {
Comment thread
chrisdeeming marked this conversation as resolved.
javaLauncher = javaToolchains.launcherFor {
languageVersion = JavaLanguageVersion.of(21)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,12 @@ class T3ComposerEditorModule : Module() {
Prop("spellCheck") { view: T3ComposerEditorView, spellCheck: Boolean ->
view.setSpellCheck(spellCheck)
}
Prop("textPasteThresholdBytes") { view: T3ComposerEditorView, threshold: Int ->
view.setTextPasteThresholdBytes(threshold)
}
Prop("maxInputChars") { view: T3ComposerEditorView, maxInputChars: Int ->
view.setMaxInputChars(maxInputChars)
}

Events(
"onComposerChange",
Expand All @@ -125,6 +131,7 @@ class T3ComposerEditorModule : Module() {
"onComposerPasteImages",
"onComposerContextPress",
"onComposerPasteContext",
"onComposerPasteText",
"onComposerContentSizeChange",
)

Expand Down
Loading
Loading