Skip to content
Merged
2 changes: 1 addition & 1 deletion templates/shared/combomarkdowneditor.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Template Attributes:
<button class="markdown-toolbar-button markdown-switch-easymde" data-tooltip-content="{{ctx.Locale.Tr "editor.buttons.switch_to_legacy.tooltip"}}">{{svg "octicon-arrow-switch"}}</button>
</div>
</markdown-toolbar>
<text-expander keys=": @" suffix="">
<text-expander keys=": @ #" multiword="#" suffix="">
<textarea class="markdown-text-editor"{{if .TextareaName}} name="{{.TextareaName}}"{{end}}{{if .TextareaPlaceholder}} placeholder="{{.TextareaPlaceholder}}"{{end}}{{if .TextareaAriaLabel}} aria-label="{{.TextareaAriaLabel}}"{{end}}{{if .DisableAutosize}} data-disable-autosize="{{.DisableAutosize}}"{{end}}>{{.TextareaContent}}</textarea>
</text-expander>
<script>
Expand Down
71 changes: 68 additions & 3 deletions web_src/js/features/comp/TextExpander.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
import {matchEmoji, matchMention} from '../../utils/match.ts';
import {matchEmoji, matchMention, matchIssue} from '../../utils/match.ts';
import {emojiString} from '../emoji.ts';
import {svg} from '../../svg.ts';

type Issue = {state: 'open' | 'closed'; pull_request: {draft: boolean; merged: boolean} | null};
function getIssueIcon(issue: Issue) {
if (issue.pull_request !== null) {
if (issue.state === 'open') {
if (issue.pull_request.draft === true) {
return 'octicon-git-pull-request-draft'; // WIP PR
}
return 'octicon-git-pull-request'; // Open PR
} else if (issue.pull_request.merged === true) {
return 'octicon-git-merge'; // Merged PR
}
return 'octicon-git-pull-request'; // Closed PR
} else if (issue.state === 'open') {
return 'octicon-issue-opened'; // Open Issue
}
return 'octicon-issue-closed'; // Closed Issue
}

function getIssueColor(issue: Issue) {
if (issue.pull_request !== null) {
if (issue.pull_request.draft === true) {
return 'grey'; // WIP PR
} else if (issue.pull_request.merged === true) {
return 'purple'; // Merged PR
}
}
if (issue.state === 'open') {
return 'green'; // Open Issue
}
return 'red'; // Closed Issue
}

export function initTextExpander(expander) {
expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => {
Expand Down Expand Up @@ -49,12 +82,44 @@ export function initTextExpander(expander) {
}

provide({matched: true, fragment: ul});
} else if (key === '#') {
provide(new Promise(async (resolve) => {
const url = window.location.href;
const matches = await matchIssue(url, text);
if (!matches.length) return resolve({matched: false});

const ul = document.createElement('ul');
ul.classList.add('suggestions');
for (const {value, name, issue} of matches) {
const li = document.createElement('li');
li.classList.add('tw-flex', 'tw-gap-2');
li.setAttribute('role', 'option');
li.setAttribute('data-value', `${key}${value}`);

const icon = document.createElement('div');
icon.innerHTML = svg(getIssueIcon(issue), 16, ['text', getIssueColor(issue)].join(' ')).trim();
li.append(icon.firstChild);

const id = document.createElement('span');
id.classList.add('id');
id.textContent = value;
li.append(id);

const nameSpan = document.createElement('span');
nameSpan.textContent = name;
li.append(nameSpan);

ul.append(li);
}

resolve({matched: true, fragment: ul});
}));
}
});
expander?.addEventListener('text-expander-value', ({detail}) => {
if (detail?.item) {
// add a space after @mentions as it's likely the user wants one
const suffix = detail.key === '@' ? ' ' : '';
// add a space after @mentions and #issue as it's likely the user wants one
const suffix = ['@', '#'].includes(detail.key) ? ' ' : '';
detail.value = `${detail.item.getAttribute('data-value')}${suffix}`;
}
});
Expand Down
119 changes: 116 additions & 3 deletions web_src/js/utils/match.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import emojis from '../../../assets/emoji.json';
import { request } from '../modules/fetch.ts';

const maxMatches = 6;

function sortAndReduce(map: Map<string, number>) {
function sortAndReduce<T>(map: Map<T, number>): T[] {
const sortedMap = new Map(Array.from(map.entries()).sort((a, b) => a[1] - b[1]));
return Array.from(sortedMap.keys()).slice(0, maxMatches);
}
Expand All @@ -27,11 +28,12 @@ export function matchEmoji(queryText: string): string[] {
return sortAndReduce(results);
}

export function matchMention(queryText: string): string[] {
type Mention = {value: string; name: string; fullname: string; avatar: string};
export function matchMention(queryText: string): Mention[] {
const query = queryText.toLowerCase();

// results is a map of weights, lower is better
const results = new Map();
const results = new Map<Mention, number>();
for (const obj of window.config.mentionValues ?? []) {
const index = obj.key.toLowerCase().indexOf(query);
if (index === -1) continue;
Expand All @@ -41,3 +43,114 @@ export function matchMention(queryText: string): string[] {

return sortAndReduce(results);
}

type Issue = {state: 'open' | 'closed'; pull_request: {draft: boolean; merged: boolean} | null};
type IssueMention = {value: string; name: string; issue: Issue};
export async function matchIssue(url: string, queryText: string): Promise<IssueMention[]> {
const query = queryText.toLowerCase();

// http://localhost:3000/anbraten/test/issues/1
// http://localhost:3000/anbraten/test/compare/main...anbraten-patch-1
const repository = (new URL(url)).pathname.split('/').slice(1, 3).join('/');
const issuePullRequestId = url.split('/').slice(-1)[0];

console.log('suggestions for 1', {
repository,
query,
});

// TODO: fetch data from api
// const res = await request('/-/suggestions', {
// method: 'GET',
// data: {
// repository,
// query,
// },
// });
// console.log(await res.json());

// results is a map of weights, lower is better
const results = new Map<IssueMention, number>();
// for (const obj of window.config.mentionValues ?? []) {
// const index = obj.key.toLowerCase().indexOf(query);
// if (index === -1) continue;
// const existing = results.get(obj);
// results.set(obj, existing ? existing - index : index);
// }

results.set({
value: '28958',
name: 'Live removal of issue comments using htmx websocket',
issue: {
state: 'open',
pull_request: {
merged: false,
draft: false,
},
},
}, 0);

results.set({
value: '32234',
name: 'Calculate `PublicOnly` for org membership only once',
issue: {
state: 'closed',
pull_request: {
merged: true,
draft: false,
},
},
}, 1);

results.set({
value: '32280',
name: 'Optimize branch protection rule loading',
issue: {
state: 'open',
pull_request: {
merged: false,
draft: false,
},
},
}, 2);

results.set({
value: '32326',
name: 'Shallow Mirroring',
issue: {
state: 'open',
pull_request: null,
},
}, 3);

results.set({
value: '32248',
name: 'Make admins adhere to branch protection rules',
issue: {
state: 'closed',
pull_request: {
merged: true,
draft: false,
},
},
}, 4);

results.set({
value: '32249',
name: 'Add a way to disable branch protection rules for admins',
issue: {
state: 'closed',
pull_request: null,
},
}, 5);

// filter out current issue/pull request
for (const [key] of results.entries()) {
if (key.value === issuePullRequestId) {
results.delete(key);
break;
}
}

return sortAndReduce(results);
}
Loading