Remove htmx (#37224)

Close #35059

Slightly improved the "fetch action" framework and started adding tests for it.

---------

Signed-off-by: silverwind <me@silverwind.io>
Co-authored-by: silverwind <me@silverwind.io>
pull/37232/head^2
wxiaoguang 2026-04-16 01:26:26 +08:00 committed by GitHub
parent 17f62bfec5
commit 2644bb8490
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 183 additions and 142 deletions

View File

@ -574,7 +574,6 @@ export default defineConfig([
'no-restricted-properties': [2, ...restrictedProperties],
'no-restricted-imports': [2, {paths: [
{name: 'jquery', message: 'Use the global $ instead', allowTypeImports: true},
{name: 'htmx.org', message: 'Use the global htmx instead', allowTypeImports: true},
]}],
'no-restricted-syntax': [2, 'WithStatement', 'ForInStatement', 'LabeledStatement', 'SequenceExpression'],
'no-return-assign': [0],
@ -1021,6 +1020,6 @@ export default defineConfig([
},
{
files: ['web_src/**/*'],
languageOptions: {globals: {...globals.browser, ...globals.jquery, htmx: false}},
languageOptions: {globals: {...globals.browser, ...globals.jquery}},
},
]);

View File

@ -49,7 +49,6 @@
"dropzone": "6.0.0-beta.2",
"easymde": "2.20.0",
"esbuild": "0.28.0",
"htmx.org": "2.0.8",
"idiomorph": "0.7.4",
"jquery": "4.0.0",
"js-yaml": "4.1.1",

View File

@ -157,9 +157,6 @@ importers:
esbuild:
specifier: 0.28.0
version: 0.28.0
htmx.org:
specifier: 2.0.8
version: 2.0.8
idiomorph:
specifier: 0.7.4
version: 0.7.4
@ -2768,9 +2765,6 @@ packages:
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
htmx.org@2.0.8:
resolution: {integrity: sha512-fm297iru0iWsNJlBrjvtN7V9zjaxd+69Oqjh4F/Vq9Wwi2kFisLcrLCiv5oBX0KLfOX/zG8AUo9ROMU5XUB44Q==}
iconv-lite@0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
@ -6789,8 +6783,6 @@ snapshots:
domutils: 3.2.2
entities: 4.5.0
htmx.org@2.0.8: {}
iconv-lite@0.6.3:
dependencies:
safer-buffer: '@nolyfill/safer-buffer@1.0.44'

View File

@ -26,6 +26,8 @@ func RenderMarkup(ctx *context.Base, ctxRepo *context.Repository, mode, text, ur
// filePath is the path of the file to render if the end user is trying to preview a repo file (mode == "file")
// filePath will be used as RenderContext.RelativePath
// TODO: MARKUP-RENDER-CONTEXT: this logic is unnecessarily complicated.
// Ideally: the "file path" should not appear in the "url path context", but it needs a lot of refactoring to achieve that
// for example, when previewing file "/gitea/owner/repo/src/branch/features/feat-123/doc/CHANGE.md", then filePath is "doc/CHANGE.md"
// and the urlPathContext is "/gitea/owner/repo/src/branch/features/feat-123/doc"

View File

@ -12,14 +12,12 @@
<div class="ui field {{if .Required}}required{{end}}">
{{if eq .Type "choice"}}
<label>{{or .Description .Name}}:</label>
{{/* htmx won't initialize the fomantic dropdown, so it is a standard "select" input */}}
<select class="ui selection dropdown" name="{{.Name}}">
{{range .Options}}
<option value="{{.}}" {{if eq $item.Default .}}selected{{end}}>{{.}}</option>
{{end}}
</select>
{{else if eq .Type "boolean"}}
{{/* htmx doesn't trigger our JS code to attach fomantic label to checkbox, so here we use standard checkbox */}}
<label class="tw-flex flex-text-inline">
<input type="checkbox" name="{{.Name}}" {{if eq .Default "true"}}checked{{end}}>
{{or .Description .Name}}

View File

@ -19,13 +19,11 @@
<div class="field">
<div class="ui top attached header">
<div class="flex-text-block tw-justify-between tw-flex-wrap">
<div class="ui compact small menu small-menu-items repo-editor-menu tw-self-start">
<div class="ui compact small menu small-menu-items repo-editor-menu" data-repo-link="{{.RepoLink}}" data-ref-sub-url="{{.RefTypeNameSubURL}}" data-branch-name="{{.BranchName}}">
<a class="active item" data-tab="write">{{svg "octicon-code"}} {{if .IsNewFile}}{{ctx.Locale.Tr "repo.editor.new_file"}}{{else}}{{ctx.Locale.Tr "repo.editor.edit_file"}}{{end}}</a>
<a class="item{{if not .CodeEditorConfig.Previewable}} tw-hidden{{end}}" data-tab="preview" data-preview-url="{{.Repository.Link}}/markup" data-preview-context-ref="{{.RepoLink}}/src/{{.RefTypeNameSubURL}}">{{svg "octicon-eye"}} {{ctx.Locale.Tr "preview"}}</a>
<a class="item {{if not .CodeEditorConfig.Previewable}}tw-hidden{{end}}" data-tab="preview">{{svg "octicon-eye"}} {{ctx.Locale.Tr "preview"}}</a>
{{if not .IsNewFile}}
{{/*FIXME: the related logic is totally a mess, need to completely rewrite, that's also the root reason for
why the "migrate to CodeMirror" PR took very long time on the legacy code and introduced "#file-name (filenameInput)" regressions many times*/}}
<a class="item" data-tab="diff" hx-params="context,content" hx-vals='{"context":"{{.BranchLink}}"}' hx-include="#edit_area" hx-swap="innerHTML" hx-target=".tab[data-tab='diff']" hx-indicator=".tab[data-tab='diff']" hx-post="{{.RepoLink}}/_preview/{{.BranchName | PathEscapeSegments}}/{{.TreePath | PathEscapeSegments}}">{{svg "octicon-diff"}} {{ctx.Locale.Tr "repo.editor.preview_changes"}}</a>
<a class="item" data-tab="diff">{{svg "octicon-diff"}} {{ctx.Locale.Tr "repo.editor.preview_changes"}}</a>
{{end}}
</div>
{{template "repo/editor/options" dict "CodeEditorConfig" $.CodeEditorConfig}}
@ -39,10 +37,10 @@
<div class="editor-loading is-loading"></div>
</div>
<div class="ui tab tw-px-4 tw-py-3" data-tab="preview">
{{ctx.Locale.Tr "loading"}}
<div class="editor-loading is-loading"></div>
</div>
<div class="ui tab" data-tab="diff">
<div class="tw-p-16"></div>
<div class="editor-loading is-loading"></div>
</div>
</div>
</div>

View File

@ -22,7 +22,7 @@
<div class="field">
<div class="ui top attached header">
<div class="flex-text-block tw-justify-between tw-flex-wrap">
<div class="ui compact small menu small-menu-items repo-editor-menu tw-self-start">
<div class="ui compact small menu small-menu-items repo-editor-menu">
<a class="active item" data-tab="write">{{svg "octicon-code" 16 "tw-mr-1"}}{{ctx.Locale.Tr "repo.editor.new_patch"}}</a>
</div>
{{template "repo/editor/options" dict "CodeEditorConfig" $.CodeEditorConfig}}

View File

@ -3,7 +3,7 @@
{{if .HasFilesWithoutLatestCommit}}
data-fetch-url="{{.LastCommitLoaderURL}}"
data-fetch-trigger="load" data-fetch-sync="$morph"
data-fetch-indicator="#repo-files-table .repo-file-cell.notready.message"
data-fetch-indicator=".repo-file-cell.notready.message"
{{end}}
>
<div class="repo-file-line repo-file-last-commit">

View File

@ -8,7 +8,7 @@ test('codeeditor textarea updates correctly', async ({page, request}) => {
try {
await page.goto(`/${env.GITEA_TEST_E2E_USER}/${repoName}/_new/main`);
await page.getByPlaceholder('Name your file…').fill('test.js');
await expect(page.locator('.editor-loading')).toBeHidden();
await expect(page.locator('[data-tab="write"] .editor-loading')).toBeHidden();
const editor = page.locator('.cm-content[role="textbox"]');
await expect(editor).toBeVisible();
await editor.click();

5
types.d.ts vendored
View File

@ -36,11 +36,6 @@ declare module '*.vue' {
export function initRepositoryActionView(): void;
}
declare module 'htmx.org/dist/htmx.esm.js' {
const value = await import('htmx.org');
export default value;
}
declare module 'idiomorph' {
interface Idiomorph {
morph(existing: Node | string, replacement: Node | string, options?: {morphStyle: 'innerHTML' | 'outerHTML'}): void;

View File

@ -40,7 +40,6 @@ const webComponents = new Set([
const commonRolldownOptions: Rolldown.RolldownOptions = {
checks: {
eval: false, // htmx needs eval
pluginTimings: false,
},
};

View File

@ -187,10 +187,6 @@ td .commit-summary {
padding: 0 !important;
}
.repo-editor-menu {
min-height: auto !important;
}
.repo-editor-header {
display: flex;
margin: 1rem 0;

View File

@ -0,0 +1,39 @@
import {execPseudoSelectorCommands} from './common-fetch-action.ts';
test('execPseudoSelectorCommands', () => {
window.document.body.innerHTML = `
<div id="d1">
<ul id="u1">
<li class="x"></li>
</ul>
<ul id="u2">
<li class="x"></li>
</ul>
</div>
<div id="d2">
<ul id="u3">
<li class="x"></li>
</ul>
</div>`;
let ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '');
expect(ret.targets).toEqual([document.querySelector('#u1')]);
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$this');
expect(ret.targets).toEqual([document.querySelector('#u1')]);
expect(ret.cmdInnerHTML).toBeFalsy();
expect(ret.cmdMorph).toBeFalsy();
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$body $morph $innerHTML');
expect(ret.targets).toEqual([document.body]);
expect(ret.cmdInnerHTML).toBeTruthy();
expect(ret.cmdMorph).toBeTruthy();
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$body .x');
expect(ret.targets.length).toEqual(3);
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('.x')));
ret = execPseudoSelectorCommands(document.querySelector('#u1 .x')!, '$closest(div) .x');
expect(ret.targets.length).toEqual(2);
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('#d1 .x')));
});

View File

@ -8,7 +8,7 @@ import {Idiomorph} from 'idiomorph';
import {parseDom} from '../utils.ts';
import {html} from '../utils/html.ts';
const {appSubUrl} = window.config;
const {appSubUrl, runModeIsProd} = window.config;
type FetchActionOpts = {
method: string;
@ -20,15 +20,24 @@ type FetchActionOpts = {
// e.g.: "$this", "$innerHTML", "$closest(tr) td .the-class", "$body #the-id"
successSync: string;
// null: no indicator
// empty string: the current element
// '.css-selector': find the element by selector
loadingIndicator: string | null;
// the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
// empty means no loading indicator, "$this" means the element itself
loadingIndicator: string;
};
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
// more details are in the backend's fetch-redirect handler
function fetchActionDoRedirect(redirect: string) {
// In production, if the link can be directly navigated by browser, we just do normal redirection, which is faster.
// Otherwise, need to use backend to do redirection:
// * Also do so in development, to make sure the redirection logic is always tested by real users
const needBackendHelp = redirect.includes('#');
if (runModeIsProd && !needBackendHelp) {
window.location.href = redirect;
return;
}
// use backend to do redirection, which can bypass the browser's limitations of "location"
const form = createElementFromHTML<HTMLFormElement>(html`<form method="post"></form>`);
form.action = `${appSubUrl}/-/fetch-redirect?redirect=${encodeURIComponent(redirect)}`;
document.body.append(form);
@ -36,9 +45,10 @@ function fetchActionDoRedirect(redirect: string) {
}
function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading: boolean) {
const loadingIndicatorElems = opt.loadingIndicator === null ? [] : (opt.loadingIndicator === '' ? [el] : document.querySelectorAll(opt.loadingIndicator));
const loadingIndicatorElems = opt.loadingIndicator ? execPseudoSelectorCommands(el, opt.loadingIndicator).targets : [];
for (const indicatorEl of loadingIndicatorElems) {
if (isLoading) {
// for button or input element, we can directly disable it, it looks better than adding a loading spinner
if ('disabled' in indicatorEl) {
indicatorEl.disabled = true;
} else {
@ -57,7 +67,7 @@ function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading
async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
if (respJson?.redirect) {
if (typeof respJson?.redirect === 'string') {
fetchActionDoRedirect(respJson.redirect);
} else {
window.location.reload();
@ -168,7 +178,7 @@ function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFet
method: formMethodUpper,
url: reqUrl,
body: reqBody,
loadingIndicator: '', // for form submit, by default, the loading indicator is the whole form
loadingIndicator: '$this', // for form submit, by default, the loading indicator is the whole form
successSync: formEl.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for form submit
};
}
@ -209,17 +219,17 @@ async function performLinkFetchAction(el: HTMLElement) {
await performActionRequest(el, {
method: el.getAttribute('data-fetch-method') || 'POST', // by default, the method is POST for link-action
url: el.getAttribute('data-url')!,
loadingIndicator: el.getAttribute('data-fetch-indicator') || '', // by default, the link-action itself is the loading indicator
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? '$this', // by default, the link-action itself is the loading indicator
successSync: el.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for link-action
});
}
type FetchActionTriggerType = 'click' | 'change' | 'every' | 'load' | 'fetch-reload';
async function performFetchActionTriggerRequest(el: HTMLElement, triggerType: FetchActionTriggerType) {
export async function performFetchActionTrigger(el: HTMLElement, triggerType: FetchActionTriggerType) {
const isUserInitiated = triggerType === 'click' || triggerType === 'change';
// for user initiated action, by default, the loading indicator is the element itself, otherwise no loading indicator
const defaultLoadingIndicator = isUserInitiated ? '' : null;
const defaultLoadingIndicator = isUserInitiated ? '$this' : '';
if (isUserInitiated) hideToastsAll();
await performActionRequest(el, {
@ -230,28 +240,51 @@ async function performFetchActionTriggerRequest(el: HTMLElement, triggerType: Fe
});
}
async function handleFetchActionSuccessSync(el: HTMLElement, successSync: string, respText: string) {
const cmds = successSync.split(' ').map((s) => s.trim()).filter(Boolean) || [];
let target = el, replaceInner = false, useMorph = false;
type PseudoSelectorCommandResult = {
targets: Element[];
cmdInnerHTML: boolean;
cmdMorph: boolean;
};
export function execPseudoSelectorCommands(el: Element, fullCommand: string): PseudoSelectorCommandResult {
const cmds = fullCommand.split(' ').map((s) => s.trim()).filter(Boolean) || [];
let targets = [el], cmdInnerHTML = false, cmdMorph = false;
for (const cmd of cmds) {
if (cmd === '$this') {
target = el;
targets = [el];
} else if (cmd === '$body') {
target = document.body;
targets = [document.body];
} else if (cmd === '$innerHTML') {
replaceInner = true;
cmdInnerHTML = true;
} else if (cmd === '$morph') {
useMorph = true;
cmdMorph = true;
} else if (cmd.startsWith('$closest(') && cmd.endsWith(')')) {
const selector = cmd.substring('$closest('.length, cmd.length - 1);
target = target.closest(selector) as HTMLElement;
const newTargets: Element[] = [];
for (const target of targets) {
const closest = target.closest(selector);
if (closest) newTargets.push(closest);
}
targets = newTargets;
} else {
target = target.querySelector(cmd) as HTMLElement;
const newTargets: Element[] = [];
for (const target of targets) {
newTargets.push(...target.querySelectorAll(cmd));
}
targets = newTargets;
}
}
if (useMorph) {
Idiomorph.morph(target, respText, {morphStyle: replaceInner ? 'innerHTML' : 'outerHTML'});
} else if (replaceInner) {
return {targets, cmdInnerHTML, cmdMorph};
}
async function handleFetchActionSuccessSync(el: Element, successSync: string, respText: string) {
const res = execPseudoSelectorCommands(el, successSync);
if (!res.targets.length) throw new Error(`Fetch-sync command "${successSync}" did not find any target element to update`);
if (res.targets.length > 1) throw new Error(`Fetch-sync command "${successSync}" found multiple target elements, which is not supported`);
const target = res.targets[0];
if (res.cmdMorph) {
Idiomorph.morph(target, respText, {morphStyle: res.cmdInnerHTML ? 'innerHTML' : 'outerHTML'});
} else if (res.cmdInnerHTML) {
target.innerHTML = respText;
} else {
target.outerHTML = respText;
@ -294,7 +327,7 @@ function initFetchActionTriggerEvery(el: HTMLElement, trigger: string) {
const intervalMs = unit === 's' ? num * 1000 : num;
const fn = async () => {
try {
await performFetchActionTriggerRequest(el, 'every');
await performFetchActionTrigger(el, 'every');
} finally {
// only continue if the element is still in the document
if (document.contains(el)) {
@ -312,15 +345,15 @@ function initFetchActionTrigger(el: HTMLElement) {
if (trigger === 'fetch-reload') return;
if (trigger === 'load') {
performFetchActionTriggerRequest(el, trigger);
performFetchActionTrigger(el, trigger);
} else if (trigger === 'change') {
el.addEventListener('change', () => performFetchActionTriggerRequest(el, trigger));
el.addEventListener('change', () => performFetchActionTrigger(el, trigger));
} else if (trigger?.startsWith('every ')) {
initFetchActionTriggerEvery(el, trigger);
} else if (!trigger || trigger === 'click') {
el.addEventListener('click', (e) => {
e.preventDefault();
performFetchActionTriggerRequest(el, 'click');
performFetchActionTrigger(el, 'click');
});
} else {
throw new Error(`Unsupported fetch trigger: ${trigger}`);
@ -328,9 +361,11 @@ function initFetchActionTrigger(el: HTMLElement) {
}
export function initGlobalFetchAction() {
// "fetch-action" is a general approach for elements to trigger fetch requests:
// The "fetch-action" framework is a general approach for elements to trigger fetch requests:
// show confirm dialog (if any), show loading indicators, send fetch request, and redirect or update UI after success.
//
// If you need more fine-grained control more details, sometimes it's clearer to write the logic in JavaScript, instead of using this generic framework.
//
// Attributes:
//
// * data-fetch-method: the HTTP method to use
@ -345,12 +380,12 @@ export function initGlobalFetchAction() {
// * "every 5s" (also support "ms" unit)
// * "fetch-reload" (only triggered by fetch sync success to reload outdated content)
//
// * data-fetch-indicator: the loading indicator element selector
// * data-fetch-indicator: the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
//
// * data-fetch-sync: when the response is text (html), the pseudo selectors/commands defined in "data-fetch-sync"
// will be used to update the content in the current page. It only supports some simple syntaxes that we need.
// "$" prefix means it is our private command (for special logic)
// * "" (empty string): replace the current element with the response
// "$" prefix means it is our private command (for special logic), the selectors are run one by one from current element.
// * "$this": replace the current element with the response
// * "$innerHTML": replace innerHTML of the current element with the response, instead of replacing the whole element (outerHTML)
// * "$morph": use morph algorithm to update the target element
// * "$body #the-id .the-class": query the selector one by one from body
@ -358,7 +393,7 @@ export function initGlobalFetchAction() {
//
// * data-modal-confirm: a "confirm modal dialog" will be shown before taking action.
// * it can be a string for the content of the modal dialog
// * it has "-header" and "-content" variants to set the header and content of the confirm modal
// * it has "-header" and "-content" variants to set the header and content of the "confirm modal"
// * it can refer an existing modal element by "#the-modal-id"
addDelegatedEventListener(document, 'submit', '.form-fetch-action', async (el: HTMLFormElement, e) => {
@ -369,7 +404,7 @@ export function initGlobalFetchAction() {
addDelegatedEventListener(document, 'click', '.link-action', async (el, e) => {
// `<a class="link-action" data-url="...">` is a shorthand for
// `<a data-fetch-trigger="click" data-fetch-method="post" data-fetch-url="..." data-fetch-indicator="">`
// `<a data-fetch-trigger="click" data-fetch-method="post" data-fetch-url="..." data-fetch-indicator="$this">`
e.preventDefault();
await performLinkFetchAction(el);
});

View File

@ -76,28 +76,26 @@ async function updateNotificationCountWithCallback(callback: (timeout: number, n
}
async function updateNotificationTable() {
let notificationDiv = document.querySelector('#notification_div');
if (notificationDiv) {
try {
const params = new URLSearchParams(window.location.search);
params.set('div-only', 'true');
params.set('sequence-number', String(++notificationSequenceNumber));
const response = await GET(`${appSubUrl}/notifications?${params.toString()}`);
const notificationDiv = document.querySelector('#notification_div');
if (!notificationDiv) return;
if (!response.ok) {
throw new Error('Failed to fetch notification table');
}
try {
const params = new URLSearchParams(window.location.search);
params.set('div-only', 'true');
params.set('sequence-number', String(++notificationSequenceNumber));
const response = await GET(`${appSubUrl}/notifications?${params.toString()}`);
const data = await response.text();
const el = createElementFromHTML(data);
if (parseInt(el.getAttribute('data-sequence-number')!) === notificationSequenceNumber) {
notificationDiv.outerHTML = data;
notificationDiv = document.querySelector('#notification_div')!;
window.htmx.process(notificationDiv); // when using htmx, we must always remember to process the new content changed by us
}
} catch (error) {
console.error(error);
if (!response.ok) {
throw new Error('Failed to fetch notification table');
}
const data = await response.text();
const el = createElementFromHTML(data);
if (parseInt(el.getAttribute('data-sequence-number')!) === notificationSequenceNumber) {
notificationDiv.outerHTML = data;
}
} catch (error) {
console.error(error);
}
}

View File

@ -9,8 +9,9 @@ import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce
import {POST, GET} from '../modules/fetch.ts';
import {createTippy} from '../modules/tippy.ts';
import {invertFileFolding} from './file-fold.ts';
import {parseDom, sleep} from '../utils.ts';
import {parseDom} from '../utils.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
import {performFetchActionTrigger} from './common-fetch-action.ts';
function initRepoDiffFileBox(el: HTMLElement) {
// switch between "rendered" and "source", for image and CSV files
@ -172,7 +173,6 @@ async function loadMoreFiles(btn: Element): Promise<boolean> {
// * append the newly loaded file list items to the existing list
const respFileBoxesChildren = Array.from(respFileBoxes.children); // "children:HTMLCollection" will be empty after replaceWith
document.querySelector('#diff-incomplete')!.replaceWith(...respFileBoxesChildren);
for (const el of respFileBoxesChildren) window.htmx.process(el);
onShowMoreFiles();
return true;
} catch (error) {
@ -204,7 +204,6 @@ function initRepoDiffShowMore() {
const respFileBody = respDoc.querySelector('#diff-file-boxes .diff-file-body .file-body')!;
const respFileBodyChildren = Array.from(respFileBody.children); // "children:HTMLCollection" will be empty after replaceWith
el.parentElement!.replaceWith(...respFileBodyChildren);
for (const el of respFileBodyChildren) window.htmx.process(el);
// FIXME: calling onShowMoreFiles is not quite right here.
// But since onShowMoreFiles mixes "init diff box" and "init diff body" together,
// so it still needs to call it to make the "ImageDiff" and something similar work.
@ -251,8 +250,8 @@ async function onLocationHashChange() {
const attrAutoLoadClicked = 'data-auto-load-clicked';
if (expandButton.hasAttribute(attrAutoLoadClicked)) return;
expandButton.setAttribute(attrAutoLoadClicked, 'true');
expandButton.click();
await sleep(500); // Wait for HTMX to load the content. FIXME: need to drop htmx in the future
// trigger the fetch action to load the hidden comments, after loading, it will try to find the target element again
await performFetchActionTrigger(expandButton, 'load');
continue; // Try again to find the element
}
}

View File

@ -6,32 +6,60 @@ import {POST} from '../modules/fetch.ts';
import {initDropzone} from './dropzone.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {applyAreYouSure, ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {submitFormFetchAction} from './common-fetch-action.ts';
import {dirname} from '../utils.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {showErrorToast} from '../modules/toast.ts';
function initEditPreviewTab(elForm: HTMLFormElement) {
const elTabMenu = elForm.querySelector('.repo-editor-menu')!;
const elTabMenu = elForm.querySelector('.repo-editor-menu');
if (!elTabMenu) return;
fomanticQuery(elTabMenu.querySelectorAll('.item')).tab();
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]');
const elPreviewPanel = elForm.querySelector('.tab[data-tab="preview"]');
if (!elPreviewTab || !elPreviewPanel) return;
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path');
const elTextarea = elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea');
if (!elTreePath || !elTextarea) return;
const repoLink = elTabMenu.getAttribute('data-repo-link')!;
const refSubUrl = elTabMenu.getAttribute('data-ref-sub-url')!;
const branchName = elTabMenu.getAttribute('data-branch-name')!;
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]')!;
const elPreviewPanel = elForm.querySelector('.tab[data-tab="preview"]')!;
elPreviewTab.addEventListener('click', async () => {
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path')!;
const previewUrl = elPreviewTab.getAttribute('data-preview-url')!;
const previewContextRef = elPreviewTab.getAttribute('data-preview-context-ref');
let previewContext = `${previewContextRef}/${elTreePath.value}`;
previewContext = previewContext.substring(0, previewContext.lastIndexOf('/'));
// "preview context" is the request path directory of the file, the rendered links will be resolved based on this path
// TODO: MARKUP-RENDER-CONTEXT: due to various hacky patches, this logic is unnecessarily complicated, see the backend
const previewContext = dirname(`${repoLink}/src/${refSubUrl}/${pathEscapeSegments(elTreePath.value)}`);
const formData = new FormData();
formData.append('mode', 'file');
formData.append('context', previewContext);
formData.append('text', elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea')!.value);
formData.append('text', elTextarea.value);
formData.append('file_path', elTreePath.value);
const response = await POST(previewUrl, {data: formData});
const data = await response.text();
const resp = await POST(`${repoLink}/markup`, {data: formData});
if (!resp.ok) {
showErrorToast(`Failed to render preview: ${resp.status} ${resp.statusText}`);
return;
}
const data = await resp.text();
renderPreviewPanelContent(elPreviewPanel, data);
});
const elDiffTab = elTabMenu.querySelector('a[data-tab="diff"]');
const elDiffPanel = elForm.querySelector('.tab[data-tab="diff"]');
if (elDiffTab && elDiffPanel) {
// the "diff" tab only exists for an existing file, but not for a new file
elDiffTab.addEventListener('click', async () => {
const diffUrl = `${repoLink}/_preview/${pathEscapeSegments(branchName)}/${pathEscapeSegments(elTreePath.value)}`;
// don't use FormData, because FormData sends "\r\n" line endings, backend assumes "\n" line endings
const resp = await POST(diffUrl, {data: new URLSearchParams({content: elTextarea.value})});
if (!resp.ok) {
showErrorToast(`Failed to render diff: ${resp.status} ${resp.statusText}`);
return;
}
elDiffPanel.innerHTML = await resp.text();
});
}
}
export function initRepoEditor() {
@ -54,6 +82,8 @@ export function initRepoEditor() {
// ATTENTION: two pages have this filename input
// * new/edit file page: there is a code editor
// * upload page: there is no code editor, but a uploader
// FIXME: the related logic is totally a mess, need to completely rewrite, that's also the root reason for
// why the "migrate to CodeMirror" PR took very long time on the legacy code and introduced "#file-name (filenameInput)" regressions many times
const filenameInput = document.querySelector<HTMLInputElement>('#file-name')!;
if (!filenameInput) return;
filenameInput.value = filenameInput.defaultValue; // prevent browser from restoring form values on refresh

View File

@ -28,12 +28,10 @@ export function syncIssueMainContentTimelineItems(oldMainContent: Element, newMa
// for event item (e.g.: "add & remove labels"), we want to replace the existing one if exists
// because the label operations can be merged into one event item, so the new item might be different from the old one
oldItem.replaceWith(newItem);
window.htmx.process(newItem);
}
continue;
}
timelineEnd.insertAdjacentElement('beforebegin', newItem);
window.htmx.process(newItem);
}
}
@ -92,7 +90,6 @@ export class IssueSidebarComboList {
// we can safely replace the whole right part (sidebar) because there are only some dropdowns and lists
const newSidebar = doc.querySelector('.issue-content-right')!;
this.elIssueSidebar.replaceWith(newSidebar);
window.htmx.process(newSidebar);
// for the main content (left side), at the moment we only support handling known timeline items
const newMainContent = doc.querySelector('.issue-content-left')!;

View File

@ -56,7 +56,6 @@ interface Window {
},
$: JQueryStatic,
jQuery: JQueryStatic,
htmx: typeof import('htmx.org').default,
_globalHandlerErrors: Array<ErrorEvent & PromiseRejectionEvent> & {
_inited: boolean,
push: (e: ErrorEvent & PromiseRejectionEvent) => void | number,

View File

@ -1,15 +1,5 @@
import jquery from 'jquery'; // eslint-disable-line no-restricted-imports
import htmx from 'htmx.org'; // eslint-disable-line no-restricted-imports
// Some users still use inline scripts and expect jQuery to be available globally.
// To avoid breaking existing users and custom plugins, import jQuery globally without ES module.
window.$ = window.jQuery = jquery;
// There is a bug in htmx, it incorrectly checks "readyState === 'complete'" when the DOM tree is ready and won't trigger DOMContentLoaded
// The bug makes htmx impossible to be loaded from an ES module: importing the htmx in onDomReady will make htmx skip its initialization.
// ref: https://github.com/bigskysoftware/htmx/pull/3365
window.htmx = htmx;
// https://htmx.org/reference/#config
htmx.config.requestClass = 'is-loading';
htmx.config.scrollIntoViewOnBoost = false;

View File

@ -1,7 +1,5 @@
import '../fomantic/build/fomantic.js';
import '../css/index.css';
import type {HtmxResponseInfo} from 'htmx.org';
import {showErrorToast} from './modules/toast.ts';
import {initDashboardRepoList} from './features/dashboard.ts';
import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
@ -173,15 +171,3 @@ const initDur = performance.now() - initStartTime;
if (initDur > 500) {
console.error(`slow init functions took ${initDur.toFixed(3)}ms`);
}
// https://htmx.org/events/#htmx:sendError
type HtmxEvent = Event & {detail: HtmxResponseInfo};
document.body.addEventListener('htmx:sendError', (event) => {
// TODO: add translations
showErrorToast(`Network error when calling ${(event as HtmxEvent).detail.requestConfig.path}`);
});
// https://htmx.org/events/#htmx:responseError
document.body.addEventListener('htmx:responseError', (event) => {
// TODO: add translations
showErrorToast(`Error ${(event as HtmxEvent).detail.xhr.status} when calling ${(event as HtmxEvent).detail.requestConfig.path}`);
});

View File

@ -1,14 +1,4 @@
// Stub APIs not implemented by happy-dom but needed by dependencies
// XPathEvaluator is used by htmx at module evaluation time
// TODO: Remove after https://github.com/capricorn86/happy-dom/pull/2103 is released
if (!globalThis.XPathEvaluator) {
globalThis.XPathEvaluator = class {
createExpression() { return {evaluate: () => ({iterateNext: () => null})} }
} as any;
}
// Dynamic import so polyfills above are applied before htmx evaluates
await import('./globals.ts');
import './globals.ts';
window.config = {
appUrl: 'http://localhost:3000/',