Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit80bd7c8unknown_key

feat(ux): Ctrl+Enter submit, code-block copy, PR author filter

feat(ux): Ctrl+Enter submit, code-block copy, PR author filter

- keyboard-ux.ts: ctrlEnterSubmitScript() submits parent form on Ctrl/Cmd+Enter; codeBlockCopyScript() adds hover copy buttons to all rendered markdown <pre> blocks
- issues.tsx + pulls.tsx: inject keyboard-ux scripts alongside mention autocomplete
- pulls.tsx: author filter input in PR list search form; ?author= param preserved in tab pill hrefs

https://claude.ai/code/session_01ACsT2Pc8GRoZwZRF8SK68Y
Claude committed on May 28, 2026Parent: d790b49
3 files changed+1561280bd7c89ad8839ccb89a752d05b97f5bad2f66d3
3 changed files+156−12
Addedsrc/lib/keyboard-ux.ts+129−0View fileUnifiedSplit
1/**
2 * Keyboard UX enhancements for comment/form pages.
3 *
4 * Two self-contained IIFE scripts injected via
5 * `<script dangerouslySetInnerHTML={{ __html: ... }} />`:
6 *
7 * - `ctrlEnterSubmitScript()` — Ctrl+Enter / Cmd+Enter submits the
8 * closest form from any focused <textarea>.
9 * - `codeBlockCopyScript()` — adds a "Copy" button to every
10 * `<pre><code>` block inside rendered markdown containers.
11 */
12
13/**
14 * Returns an IIFE string that intercepts Ctrl+Enter (and Cmd+Enter on Mac)
15 * on any <textarea> and submits its parent <form>.
16 *
17 * If the form has a primary submit button it is clicked (triggering any
18 * formaction / validation logic); otherwise `form.submit()` is called as
19 * a fallback.
20 */
21export function ctrlEnterSubmitScript(): string {
22 return `(function(){
23 try{
24 document.addEventListener('keydown',function(e){
25 if(!(e.ctrlKey||e.metaKey))return;
26 if(e.key!=='Enter')return;
27 var ta=e.target;
28 if(!ta||ta.tagName!=='TEXTAREA')return;
29 e.preventDefault();
30 var form=ta.closest('form');
31 if(!form)return;
32 var submitBtn=form.querySelector('button[type="submit"],input[type="submit"]');
33 if(submitBtn){submitBtn.click();}else{form.submit();}
34 });
35 }catch(ex){}
36})();`;
37}
38
39/**
40 * Returns an IIFE string that adds a floating "Copy" button to every
41 * `<pre>` inside rendered markdown containers (.markdown-content,
42 * .prs-comment-body, .prs-body, .issue-body, .iss-body, .markdown-body).
43 *
44 * The button is injected on DOMContentLoaded and re-checked whenever new
45 * nodes are added via MutationObserver (for dynamically loaded content).
46 *
47 * Scoped CSS is injected once into <head>; a data attribute guards against
48 * double-injection on the same <pre>.
49 */
50export function codeBlockCopyScript(): string {
51 return `(function(){
52 try{
53 var _ccCss='.code-copy-btn{position:absolute;top:6px;right:6px;padding:3px 8px;font-size:11px;font-weight:600;border-radius:6px;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.08);color:rgba(255,255,255,0.70);cursor:pointer;opacity:0;transition:opacity 120ms;}.pre:hover .code-copy-btn,.code-copy-btn:focus{opacity:1;}pre:hover .code-copy-btn,.code-copy-btn:focus{opacity:1;}.code-copy-btn.copied{color:#34d399;border-color:rgba(52,211,153,0.40);}.code-copy-wrap{position:relative;}';
54 function injectCss(){if(document.getElementById('cc-style'))return;var s=document.createElement('style');s.id='cc-style';s.textContent=_ccCss;document.head.appendChild(s);}
55
56 function addCopyButtons(){
57 injectCss();
58 var pres=document.querySelectorAll('.markdown-content pre,.prs-comment-body pre,.prs-body pre,.issue-body pre,.iss-body pre,.markdown-body pre');
59 for(var i=0;i<pres.length;i++){
60 var pre=pres[i];
61 if(pre.getAttribute('data-copy-wired'))continue;
62 pre.setAttribute('data-copy-wired','1');
63
64 /* Wrap in a position:relative container if not already wrapped */
65 var parent=pre.parentElement;
66 var wrap;
67 if(parent&&parent.classList&&parent.classList.contains('code-copy-wrap')){
68 wrap=parent;
69 }else{
70 wrap=document.createElement('div');
71 wrap.className='code-copy-wrap';
72 parent.insertBefore(wrap,pre);
73 wrap.appendChild(pre);
74 }
75
76 /* Build the copy button */
77 var btn=document.createElement('button');
78 btn.type='button';
79 btn.className='code-copy-btn';
80 btn.textContent='Copy';
81 btn.setAttribute('aria-label','Copy code to clipboard');
82 (function(b,p){
83 b.addEventListener('click',function(){
84 var text=p.textContent||'';
85 if(!navigator.clipboard){
86 /* Fallback for non-HTTPS or older browsers */
87 try{
88 var ta=document.createElement('textarea');
89 ta.value=text;ta.style.position='fixed';ta.style.top='-9999px';
90 document.body.appendChild(ta);ta.select();
91 document.execCommand('copy');
92 document.body.removeChild(ta);
93 }catch(er){return;}
94 b.classList.add('copied');b.textContent='Copied!';
95 setTimeout(function(){b.classList.remove('copied');b.textContent='Copy';},1500);
96 return;
97 }
98 navigator.clipboard.writeText(text).then(function(){
99 b.classList.add('copied');b.textContent='Copied!';
100 setTimeout(function(){b.classList.remove('copied');b.textContent='Copy';},1500);
101 }).catch(function(){});
102 });
103 })(btn,pre);
104
105 wrap.appendChild(btn);
106 }
107 }
108
109 if(document.readyState==='loading'){
110 document.addEventListener('DOMContentLoaded',addCopyButtons);
111 }else{
112 addCopyButtons();
113 }
114
115 /* Watch for dynamically inserted markdown containers */
116 if(typeof MutationObserver!=='undefined'){
117 var obs=new MutationObserver(function(mutations){
118 for(var m=0;m<mutations.length;m++){
119 if(mutations[m].addedNodes&&mutations[m].addedNodes.length){
120 addCopyButtons();
121 break;
122 }
123 }
124 });
125 obs.observe(document.body,{childList:true,subtree:true});
126 }
127 }catch(ex){}
128})();`;
129}
Modifiedsrc/routes/issues.tsx+2−0View fileUnifiedSplit
2424import { liveCommentBannerScript } from "../lib/sse-client";
2525import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
2626import { markdownPreviewScript } from "../lib/markdown-preview";
27import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
2728import { triggerIssueTriage, ISSUE_TRIAGE_MARKER } from "../lib/issue-triage";
2829import { softAuth, requireAuth } from "../middleware/auth";
2930import type { AuthEnv } from "../middleware/auth";
12661267 />
12671268 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
12681269 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
1270 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
12691271 <div class="issue-detail">
12701272 {info && (
12711273 <div class="issues-info-banner">
Modifiedsrc/routes/pulls.tsx+25−12View fileUnifiedSplit
4242import { liveCommentBannerScript } from "../lib/sse-client";
4343import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
4444import { markdownPreviewScript } from "../lib/markdown-preview";
45import { ctrlEnterSubmitScript, codeBlockCopyScript } from "../lib/keyboard-ux";
4546import { softAuth, requireAuth } from "../middleware/auth";
4647import type { AuthEnv } from "../middleware/auth";
4748import { requireRepoAccess } from "../middleware/repo-access";
18581859 const user = c.get("user");
18591860 const state = c.req.query("state") || "open";
18601861 const searchQ = c.req.query("q")?.trim() || "";
1862 const authorFilter = c.req.query("author")?.trim() || "";
18611863
18621864 // ── Loading skeleton (flag-gated) ──
18631865 // Renders an SSR'd PR-row skeleton when `?skeleton=1` is set. Lets
19201922 eq(pullRequests.repositoryId, resolved.repo.id),
19211923 stateFilter,
19221924 searchQ ? ilike(pullRequests.title, `%${searchQ}%`) : undefined,
1925 authorFilter ? eq(users.username, authorFilter) : undefined,
19231926 )
19241927 )
19251928 .orderBy(desc(pullRequests.createdAt));
19731976 // "All" is presentational only — the DB query for state='all' matches
19741977 // nothing, so we render a friendlier empty state when picked. We do NOT
19751978 // change the query logic to keep this commit purely visual.
1979 const authorQs = authorFilter ? `&author=${encodeURIComponent(authorFilter)}` : "";
19761980 const tabPills: Array<{ label: string; count: number; key: string; href: string }> = [
1977 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open` },
1978 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged` },
1979 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed` },
1980 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all` },
1981 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft` },
1981 { label: "Open", count: openCount, key: "open", href: `/${ownerName}/${repoName}/pulls?state=open${authorQs}` },
1982 { label: "Merged", count: mergedCount, key: "merged", href: `/${ownerName}/${repoName}/pulls?state=merged${authorQs}` },
1983 { label: "Closed", count: closedCount, key: "closed", href: `/${ownerName}/${repoName}/pulls?state=closed${authorQs}` },
1984 { label: "All", count: allCount, key: "all", href: `/${ownerName}/${repoName}/pulls?state=all${authorQs}` },
1985 { label: "Draft", count: draftCount, key: "draft", href: `/${ownerName}/${repoName}/pulls?state=draft${authorQs}` },
19821986 ];
19831987 const isAllState = state === "all";
19841988 const viewerIsOwnerOnPrList = !!(user && user.id === resolved.owner.id);
20592063 class="issues-search-input"
20602064 style="flex:1;max-width:380px"
20612065 />
2066 <input
2067 type="text"
2068 name="author"
2069 value={authorFilter}
2070 placeholder="Filter by author…"
2071 class="issues-search-input"
2072 style="max-width:200px"
2073 />
20622074 <button type="submit" class="issues-search-btn" aria-label="Search">{"🔍"}</button>
2063 {searchQ && (
2075 {(searchQ || authorFilter) && (
20642076 <a
20652077 href={`/${ownerName}/${repoName}/pulls?state=${state}`}
20662078 class="issues-filter-clear"
20732085 <div class="prs-empty">
20742086 <div class="prs-empty-inner">
20752087 <strong>
2076 {searchQ
2077 ? `No pull requests match "${searchQ}"`
2088 {searchQ || authorFilter
2089 ? `No pull requests match${searchQ ? ` "${searchQ}"` : ""}${authorFilter ? ` by "${authorFilter}"` : ""}`
20782090 : isAllState
20792091 ? "Pick a filter above to browse PRs."
20802092 : `No ${state} pull requests.`}
20812093 </strong>
20822094 <p class="prs-empty-sub">
2083 {searchQ
2084 ? `Try a different search term or clear the filter.`
2095 {searchQ || authorFilter
2096 ? `Try a different search term or author, or clear the filter.`
20852097 : state === "open"
20862098 ? "Pull requests propose changes from a branch into the base. Open one to kick off AI review, gate checks, and (if eligible) auto-merge."
20872099 : isAllState
20892101 : `No ${state} pull requests on ${ownerName}/${repoName} right now. Try a different filter.`}
20902102 </p>
20912103 <div class="prs-empty-cta">
2092 {user && state === "open" && !searchQ && (
2104 {user && state === "open" && !searchQ && !authorFilter && (
20932105 <a href={`/${ownerName}/${repoName}/pulls/new`} class="btn btn-primary">
20942106 + New pull request
20952107 </a>
20962108 )}
2097 {state !== "open" && !searchQ && (
2109 {state !== "open" && !searchQ && !authorFilter && (
20982110 <a href={`/${ownerName}/${repoName}/pulls?state=open`} class="btn">
20992111 View open PRs
21002112 </a>
35563568 />
35573569 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
35583570 <script dangerouslySetInnerHTML={{ __html: markdownPreviewScript() }} />
3571 <script dangerouslySetInnerHTML={{ __html: ctrlEnterSubmitScript() + codeBlockCopyScript() }} />
35593572
35603573 <nav class="prs-detail-tabs" aria-label="Pull request sections">
35613574 <a
35623575