Skip to content

Commit 5a77f55

Browse files
feat(browser): native webview with controls in tab bar
Browser pane is a native child webview (add_child) — loads any site. URL bar, DevTools, element inspector, and close button are all in the tab bar row (above the webview, not inside the pane). Element inspector injects JS via eval() to highlight and extract HTML/CSS on click.
1 parent a1ac69a commit 5a77f55

7 files changed

Lines changed: 286 additions & 266 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/tauri-app/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ edition.workspace = true
77
tauri-build = { version = "2", features = [] }
88

99
[dependencies]
10-
tauri = { version = "2", features = [] }
10+
tauri = { version = "2", features = ["unstable", "devtools"] }
1111
tauri-plugin-dialog = "2"
1212
tauri-plugin-shell = "2"
1313
codeforge-session = { path = "../session" }
@@ -21,3 +21,4 @@ uuid = { workspace = true }
2121
chrono = { workspace = true }
2222
anyhow = { workspace = true }
2323
rusqlite = "0.31"
24+
url = "2"
Lines changed: 52 additions & 262 deletions
Original file line numberDiff line numberDiff line change
@@ -1,275 +1,65 @@
1-
import { createSignal, Show, onMount, onCleanup } from "solid-js";
1+
import { onMount, onCleanup } from "solid-js";
22
import { appStore } from "../../stores/app-store";
3+
import * as ipc from "../../ipc";
4+
5+
// Track created webviews
6+
const created = new Set<string>();
37

48
interface Props {
59
threadId: string;
610
}
711

12+
/**
13+
* This component is JUST a placeholder div. The native webview
14+
* is positioned over it by Rust. All controls (URL bar, buttons)
15+
* live in the TabBar component above — NOT here — because the
16+
* native webview renders on top of all DOM content.
17+
*/
818
export function BrowserPanel(props: Props) {
9-
const { store, setStore } = appStore;
10-
const [url, setUrl] = createSignal(store.threadBrowserUrls[props.threadId] || "https://en.wikipedia.org");
11-
const [loadedUrl, setLoadedUrl] = createSignal(url());
12-
const [inspecting, setInspecting] = createSignal(false);
13-
const [pasteMode, setPasteMode] = createSignal(false);
14-
const [pasteHtml, setPasteHtml] = createSignal("");
15-
let iframeRef: HTMLIFrameElement | undefined;
16-
17-
function navigate() {
18-
let u = url().trim();
19-
if (!u) return;
20-
if (!u.match(/^https?:\/\//)) u = "https://" + u;
21-
setUrl(u);
22-
setLoadedUrl(u);
23-
setStore("threadBrowserUrls", props.threadId, u);
24-
}
25-
26-
function close() {
27-
setStore("threadBrowserOpen", props.threadId, false);
28-
}
29-
30-
// Try to inject inspector into iframe (works for same-origin only)
31-
function toggleInspect() {
32-
const next = !inspecting();
33-
setInspecting(next);
34-
if (next) {
35-
injectInspector();
36-
} else {
37-
removeInspector();
38-
}
39-
}
40-
41-
function injectInspector() {
42-
try {
43-
const doc = iframeRef?.contentDocument;
44-
if (!doc) {
45-
// Cross-origin — fall back to paste mode
46-
setPasteMode(true);
47-
setInspecting(false);
48-
return;
49-
}
50-
if (doc.getElementById("cf-inspector")) return;
51-
52-
const script = doc.createElement("script");
53-
script.id = "cf-inspector";
54-
script.textContent = `
55-
(function() {
56-
var ov = document.createElement('div');
57-
ov.id = 'cf-ov';
58-
ov.style.cssText = 'position:fixed;pointer-events:none;z-index:999999;border:2px solid #6b7cff;background:rgba(107,124,255,0.06);display:none;transition:all .08s;';
59-
document.body.appendChild(ov);
60-
var last = null;
61-
function onM(e) {
62-
var el = document.elementFromPoint(e.clientX, e.clientY);
63-
if (!el || el === ov) return;
64-
last = el;
65-
var r = el.getBoundingClientRect();
66-
ov.style.display = 'block';
67-
ov.style.left = r.left + 'px';
68-
ov.style.top = r.top + 'px';
69-
ov.style.width = r.width + 'px';
70-
ov.style.height = r.height + 'px';
71-
}
72-
function getStyles(el) {
73-
var cs = getComputedStyle(el);
74-
var d = document.createElement(el.tagName);
75-
document.body.appendChild(d);
76-
var ds = getComputedStyle(d);
77-
var out = {};
78-
var keep = ['color','background','background-color','font-size','font-weight','font-family',
79-
'padding','margin','border','border-radius','display','flex-direction','align-items',
80-
'justify-content','gap','width','height','max-width','position','box-shadow','text-align',
81-
'line-height','letter-spacing','overflow'];
82-
for (var i = 0; i < keep.length; i++) {
83-
var v = cs.getPropertyValue(keep[i]);
84-
var dv = ds.getPropertyValue(keep[i]);
85-
if (v && v !== dv && v !== 'none' && v !== 'normal' && v !== 'auto' && v !== '0px')
86-
out[keep[i]] = v;
87-
}
88-
d.remove();
89-
return out;
90-
}
91-
function onC(e) {
92-
e.preventDefault(); e.stopPropagation();
93-
if (!last) return;
94-
var html = last.outerHTML;
95-
if (html.length > 3000) html = html.substring(0, 3000) + '...';
96-
var css = JSON.stringify(getStyles(last), null, 2);
97-
window.parent.postMessage({ type: 'cf-extract', html: html, css: css }, '*');
98-
}
99-
document.addEventListener('mousemove', onM, true);
100-
document.addEventListener('click', onC, true);
101-
window.__cfClean = function() {
102-
document.removeEventListener('mousemove', onM, true);
103-
document.removeEventListener('click', onC, true);
104-
ov.remove();
105-
};
106-
})();
107-
`;
108-
doc.head.appendChild(script);
109-
} catch {
110-
// Cross-origin — show paste fallback
111-
setPasteMode(true);
112-
setInspecting(false);
113-
}
114-
}
115-
116-
function removeInspector() {
117-
try {
118-
const doc = iframeRef?.contentDocument;
119-
if (!doc) return;
120-
if ((doc.defaultView as any)?.__cfClean) (doc.defaultView as any).__cfClean();
121-
doc.getElementById("cf-inspector")?.remove();
122-
} catch {}
123-
}
124-
125-
// Listen for extraction messages from the iframe
126-
function onMessage(e: MessageEvent) {
127-
if (e.data?.type === "cf-extract") {
128-
addExtraction(e.data.html, e.data.css);
129-
setInspecting(false);
130-
removeInspector();
131-
}
19+
const { store } = appStore;
20+
let ref: HTMLDivElement | undefined;
21+
let lastKey = "";
22+
23+
function sync() {
24+
if (!ref) return;
25+
const r = ref.getBoundingClientRect();
26+
if (r.width < 10 || r.height < 10) return;
27+
const key = `${Math.round(r.left)},${Math.round(r.top)},${Math.round(r.width)},${Math.round(r.height)}`;
28+
if (key === lastKey) return;
29+
lastKey = key;
30+
ipc.browserSetBounds(props.threadId, Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)).catch(() => {});
13231
}
13332

13433
onMount(() => {
135-
window.addEventListener("message", onMessage);
136-
onCleanup(() => window.removeEventListener("message", onMessage));
34+
const t = setTimeout(() => {
35+
if (!ref) return;
36+
const r = ref.getBoundingClientRect();
37+
if (r.width < 10 || r.height < 10) return;
38+
const url = store.threadBrowserUrls[props.threadId] || "https://google.com";
39+
40+
if (created.has(props.threadId)) {
41+
// Already exists — just reposition
42+
ipc.browserSetBounds(props.threadId, Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height)).catch(() => {});
43+
} else {
44+
ipc.browserOpen(props.threadId, url, Math.round(r.left), Math.round(r.top), Math.round(r.width), Math.round(r.height))
45+
.then(() => created.add(props.threadId))
46+
.catch((e) => console.error("browser_open:", e));
47+
}
48+
}, 300);
49+
50+
const ro = new ResizeObserver(() => sync());
51+
if (ref) ro.observe(ref);
52+
window.addEventListener("resize", sync);
53+
const poll = setInterval(sync, 150);
54+
55+
onCleanup(() => {
56+
clearTimeout(t);
57+
ro.disconnect();
58+
window.removeEventListener("resize", sync);
59+
clearInterval(poll);
60+
ipc.browserHide(props.threadId).catch(() => {});
61+
});
13762
});
13863

139-
function addExtraction(html: string, css: string) {
140-
const formatted = `Extracted from ${loadedUrl()}:\n\n\`\`\`html\n${html}\n\`\`\`\n\n\`\`\`css\n${css}\n\`\`\``;
141-
// Add as attachment-style context (prepend to composer)
142-
const current = store.composerText;
143-
setStore("composerText", current ? formatted + "\n\n" + current : formatted);
144-
}
145-
146-
function extractFromPaste() {
147-
const html = pasteHtml().trim();
148-
if (!html) return;
149-
addExtraction(html, "/* Paste mode — styles not available */");
150-
setPasteHtml("");
151-
setPasteMode(false);
152-
}
153-
154-
return (
155-
<div class="bp">
156-
<div class="bp-bar">
157-
<input
158-
class="bp-url"
159-
value={url()}
160-
onInput={(e) => setUrl(e.currentTarget.value)}
161-
onKeyDown={(e) => { if (e.key === "Enter") navigate(); }}
162-
placeholder="URL..."
163-
/>
164-
<button class="bp-go" onClick={navigate}>Go</button>
165-
<button
166-
class="bp-btn"
167-
classList={{ active: inspecting() }}
168-
onClick={toggleInspect}
169-
title="Inspect element — click to extract HTML/CSS"
170-
>
171-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
172-
<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 013 3L7 19l-4 1 1-4z"/>
173-
</svg>
174-
</button>
175-
<button
176-
class="bp-btn"
177-
classList={{ active: pasteMode() }}
178-
onClick={() => setPasteMode(!pasteMode())}
179-
title="Paste HTML manually"
180-
>
181-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
182-
<path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2"/>
183-
<rect x="8" y="2" width="8" height="4" rx="1"/>
184-
</svg>
185-
</button>
186-
<button class="bp-btn" onClick={close} title="Close">
187-
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
188-
</button>
189-
</div>
190-
191-
<Show when={inspecting()}>
192-
<div class="bp-hint">Click an element in the page to extract its HTML and CSS</div>
193-
</Show>
194-
195-
<Show when={pasteMode()}>
196-
<div class="bp-paste">
197-
<textarea
198-
class="bp-paste-area"
199-
value={pasteHtml()}
200-
onInput={(e) => setPasteHtml(e.currentTarget.value)}
201-
placeholder="Paste outerHTML from DevTools here..."
202-
/>
203-
<button class="bp-paste-btn" onClick={extractFromPaste} disabled={!pasteHtml().trim()}>
204-
Extract to composer
205-
</button>
206-
</div>
207-
</Show>
208-
209-
<Show when={!pasteMode()}>
210-
<iframe
211-
ref={iframeRef}
212-
class="bp-frame"
213-
src={loadedUrl()}
214-
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
215-
/>
216-
</Show>
217-
</div>
218-
);
219-
}
220-
221-
if (!document.getElementById("bp-styles")) {
222-
const s = document.createElement("style");
223-
s.id = "bp-styles";
224-
s.textContent = `
225-
.bp { flex:1; display:flex; flex-direction:column; min-height:0; overflow:hidden; }
226-
.bp-bar {
227-
display:flex; align-items:center; gap:3px; padding:5px 6px;
228-
border-bottom:1px solid var(--border); background:var(--bg-surface); flex-shrink:0;
229-
}
230-
.bp-btn {
231-
width:24px;height:24px;border-radius:var(--radius-sm);
232-
display:flex;align-items:center;justify-content:center;
233-
color:var(--text-tertiary);transition:background .1s,color .1s;flex-shrink:0;
234-
}
235-
.bp-btn:hover { background:var(--bg-accent);color:var(--text-secondary); }
236-
.bp-btn.active { color:var(--primary);background:rgba(107,124,255,0.15); }
237-
.bp-url {
238-
flex:1;min-width:0;height:24px;padding:0 8px;font-size:12px;
239-
font-family:var(--font-mono);background:var(--bg-muted);
240-
border:1px solid var(--border);border-radius:var(--radius-sm);
241-
color:var(--text);outline:none;
242-
}
243-
.bp-url:focus { border-color:var(--primary); }
244-
.bp-go {
245-
height:24px;padding:0 10px;font-size:11px;font-weight:600;
246-
background:var(--primary);color:#fff;border-radius:var(--radius-sm);flex-shrink:0;
247-
}
248-
.bp-go:hover { filter:brightness(1.15); }
249-
.bp-frame { flex:1; min-height:0; border:none; background:#fff; }
250-
.bp-hint {
251-
padding:4px 10px;font-size:10px;font-weight:500;
252-
color:var(--primary);background:var(--bg-muted);
253-
border-bottom:1px solid var(--border);text-align:center;flex-shrink:0;
254-
}
255-
.bp-paste {
256-
flex:1;display:flex;flex-direction:column;padding:10px;gap:8px;
257-
background:var(--bg-base);min-height:0;
258-
}
259-
.bp-paste-area {
260-
flex:1;min-height:60px;padding:8px;font-size:12px;
261-
font-family:var(--font-mono);background:var(--bg-surface);
262-
border:1px solid var(--border);border-radius:var(--radius-sm);
263-
color:var(--text);resize:none;outline:none;
264-
}
265-
.bp-paste-area:focus { border-color:var(--primary); }
266-
.bp-paste-area::placeholder { color:var(--text-tertiary); }
267-
.bp-paste-btn {
268-
align-self:flex-end;height:28px;padding:0 14px;font-size:11px;font-weight:600;
269-
background:var(--primary);color:#fff;border-radius:var(--radius-sm);
270-
}
271-
.bp-paste-btn:hover { filter:brightness(1.15); }
272-
.bp-paste-btn:disabled { opacity:0.4;cursor:not-allowed; }
273-
`;
274-
document.head.appendChild(s);
64+
return <div ref={ref} style="flex:1;min-height:0;min-width:0;" />;
27565
}

0 commit comments

Comments
 (0)