Skip to content

Add repo file tree item link behavior #34730

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 23 commits into from
Jun 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
50bf708
Open new tab when clicking file tree with mouse wheel
bytedream May 28, 2025
83e925a
Use own function instead
bytedream May 29, 2025
0941d5f
Merge branch 'main' into mouse-wheel-click-file-tree
bytedream Jun 15, 2025
91069cf
use click.middle instead of auxclick
bytedream Jun 15, 2025
2b5e119
also check if ctrl key was pressed
bytedream Jun 16, 2025
7fef0bb
Merge branch 'main' into mouse-wheel-click-file-tree
bytedream Jun 16, 2025
06ac1c9
update comment
bytedream Jun 16, 2025
10d9471
check meta key in addition to ctrl
bytedream Jun 16, 2025
9c4a8e2
add aux click check to doLoadDirContent
bytedream Jun 16, 2025
9915581
add sub module aux click support
bytedream Jun 16, 2025
3c6e803
Merge branch 'main' into mouse-wheel-click-file-tree
bytedream Jun 18, 2025
ee2f52a
use `a` for file tree files
bytedream Jun 18, 2025
2bf13d0
Merge branch 'main' into mouse-wheel-click-file-tree
bytedream Jun 18, 2025
0c8c45f
use class to disable default link styling
bytedream Jun 18, 2025
e2655f3
move plain click check to utils
bytedream Jun 18, 2025
35ab11f
mark mouse event explicitly null
bytedream Jun 18, 2025
ed23d70
update docs
bytedream Jun 18, 2025
7914f27
Merge branch 'main' into mouse-wheel-click-file-tree
wxiaoguang Jun 19, 2025
156de30
refactor
wxiaoguang Jun 19, 2025
5519577
refactor
wxiaoguang Jun 19, 2025
ef3873c
refactor
wxiaoguang Jun 19, 2025
f01e51f
prevent page reload on subtree load
bytedream Jun 19, 2025
390b63e
Merge branch 'main' into mouse-wheel-click-file-tree
GiteaBot Jun 19, 2025
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: 1 addition & 1 deletion web_src/js/components/DiffFileTree.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ function updateState(visible: boolean) {
</script>

<template>
<!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
<div v-if="store.fileTreeIsVisible" class="diff-file-tree-items">
<!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
<DiffFileTreeItem v-for="item in store.diffFileTree.TreeRoot.Children" :key="item.FullName" :item="item"/>
</div>
</template>
Expand Down
46 changes: 6 additions & 40 deletions web_src/js/components/ViewFileTree.vue
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
<script lang="ts" setup>
import ViewFileTreeItem from './ViewFileTreeItem.vue';
import {onMounted, ref} from 'vue';
import {pathEscapeSegments} from '../utils/url.ts';
import {GET} from '../modules/fetch.ts';
import {createElementFromHTML} from '../utils/dom.ts';
import {createViewFileTreeStore} from './ViewFileTreeStore.ts';

const elRoot = ref<HTMLElement | null>(null);

Expand All @@ -13,52 +11,20 @@ const props = defineProps({
currentRefNameSubURL: {type: String, required: true},
});

const files = ref([]);
const selectedItem = ref('');

async function loadChildren(treePath: string, subPath: string = '') {
const response = await GET(`${props.repoLink}/tree-view/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}?sub_path=${encodeURIComponent(subPath)}`);
const json = await response.json();
const poolSvgs = [];
for (const [svgId, svgContent] of Object.entries(json.renderedIconPool ?? {})) {
if (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent);
}
if (poolSvgs.length) {
const svgContainer = createElementFromHTML('<div class="global-svg-icon-pool tw-hidden"></div>');
svgContainer.innerHTML = poolSvgs.join('');
document.body.append(svgContainer);
}
return json.fileTreeNodes ?? null;
}

async function loadViewContent(url: string) {
url = url.includes('?') ? url.replace('?', '?only_content=true') : `${url}?only_content=true`;
const response = await GET(url);
document.querySelector('.repo-view-content').innerHTML = await response.text();
}

async function navigateTreeView(treePath: string) {
const url = `${props.repoLink}/src/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}`;
window.history.pushState({treePath, url}, null, url);
selectedItem.value = treePath;
await loadViewContent(url);
}

const store = createViewFileTreeStore(props);
onMounted(async () => {
selectedItem.value = props.treePath;
files.value = await loadChildren('', props.treePath);
store.rootFiles = await store.loadChildren('', props.treePath);
elRoot.value.closest('.is-loading')?.classList?.remove('is-loading');
window.addEventListener('popstate', (e) => {
selectedItem.value = e.state?.treePath || '';
if (e.state?.url) loadViewContent(e.state.url);
store.selectedItem = e.state?.treePath || '';
if (e.state?.url) store.loadViewContent(e.state.url);
});
});
</script>

<template>
<div class="view-file-tree-items" ref="elRoot">
<!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
<ViewFileTreeItem v-for="item in files" :key="item.name" :item="item" :selected-item="selectedItem" :navigate-view-content="navigateTreeView" :load-children="loadChildren"/>
<ViewFileTreeItem v-for="item in store.rootFiles" :key="item.name" :item="item" :store="store"/>
</div>
</template>

Expand Down
94 changes: 30 additions & 64 deletions web_src/js/components/ViewFileTreeItem.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
import {isPlainClick} from '../utils/dom.ts';
import {ref} from 'vue';
import {type createViewFileTreeStore} from './ViewFileTreeStore.ts';

type Item = {
entryName: string;
entryMode: string;
entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown';
entryIcon: string;
entryIconOpen: string;
fullPath: string;
Expand All @@ -14,103 +16,67 @@ type Item = {

const props = defineProps<{
item: Item,
navigateViewContent:(treePath: string) => void,
loadChildren:(treePath: string, subPath?: string) => Promise<Item[]>,
selectedItem?: string,
store: ReturnType<typeof createViewFileTreeStore>
}>();

const store = props.store;
const isLoading = ref(false);
const children = ref(props.item.children);
const collapsed = ref(!props.item.children);

const doLoadChildren = async () => {
collapsed.value = !collapsed.value;
if (!collapsed.value && props.loadChildren) {
if (!collapsed.value) {
isLoading.value = true;
try {
children.value = await props.loadChildren(props.item.fullPath);
children.value = await store.loadChildren(props.item.fullPath);
} finally {
isLoading.value = false;
}
}
};

const doLoadDirContent = () => {
doLoadChildren();
props.navigateViewContent(props.item.fullPath);
const onItemClick = (e: MouseEvent) => {
// only handle the click event with page partial reloading if the user didn't press any special key
// let browsers handle special keys like "Ctrl+Click"
if (!isPlainClick(e)) return;
e.preventDefault();
if (props.item.entryMode === 'tree') doLoadChildren();
store.navigateTreeView(props.item.fullPath);
};

const doLoadFileContent = () => {
props.navigateViewContent(props.item.fullPath);
};

const doGotoSubModule = () => {
location.href = props.item.submoduleUrl;
};
</script>

<!--title instead of tooltip above as the tooltip needs too much work with the current methods, i.e. not being loaded or staying open for "too long"-->
<template>
<div
v-if="item.entryMode === 'commit'" class="tree-item type-submodule"
:title="item.entryName"
@click.stop="doGotoSubModule"
>
<!-- submodule -->
<div class="item-content">
<!-- eslint-disable-next-line vue/no-v-html -->
<span class="tw-contents" v-html="item.entryIcon"/>
<span class="gt-ellipsis tw-flex-1">{{ item.entryName }}</span>
</div>
</div>
<div
v-else-if="item.entryMode === 'symlink'" class="tree-item type-symlink"
:class="{'selected': selectedItem === item.fullPath}"
<a
class="tree-item silenced"
:class="{
'selected': store.selectedItem === item.fullPath,
'type-submodule': item.entryMode === 'commit',
'type-directory': item.entryMode === 'tree',
'type-symlink': item.entryMode === 'symlink',
'type-file': item.entryMode === 'blob' || item.entryMode === 'exec',
}"
:title="item.entryName"
@click.stop="doLoadFileContent"
:href="store.buildTreePathWebUrl(item.fullPath)"
@click.stop="onItemClick"
>
<!-- symlink -->
<div class="item-content">
<!-- eslint-disable-next-line vue/no-v-html -->
<span class="tw-contents" v-html="item.entryIcon"/>
<span class="gt-ellipsis tw-flex-1">{{ item.entryName }}</span>
</div>
</div>
<div
v-else-if="item.entryMode !== 'tree'" class="tree-item type-file"
:class="{'selected': selectedItem === item.fullPath}"
:title="item.entryName"
@click.stop="doLoadFileContent"
>
<!-- file -->
<div class="item-content">
<!-- eslint-disable-next-line vue/no-v-html -->
<span class="tw-contents" v-html="item.entryIcon"/>
<span class="gt-ellipsis tw-flex-1">{{ item.entryName }}</span>
</div>
</div>
<div
v-else class="tree-item type-directory"
:class="{'selected': selectedItem === item.fullPath}"
:title="item.entryName"
@click.stop="doLoadDirContent"
>
<!-- directory -->
<div class="item-toggle">
<div v-if="item.entryMode === 'tree'" class="item-toggle">
<SvgIcon v-if="isLoading" name="octicon-sync" class="circular-spin"/>
<SvgIcon v-else :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'" @click.stop="doLoadChildren"/>
<SvgIcon v-else :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'" @click.stop.prevent="doLoadChildren"/>
</div>
<div class="item-content">
<!-- eslint-disable-next-line vue/no-v-html -->
<span class="tw-contents" v-html="(!collapsed && item.entryIconOpen) ? item.entryIconOpen : item.entryIcon"/>
<span class="gt-ellipsis">{{ item.entryName }}</span>
</div>
</div>
</a>

<div v-if="children?.length" v-show="!collapsed" class="sub-items">
<ViewFileTreeItem v-for="childItem in children" :key="childItem.entryName" :item="childItem" :selected-item="selectedItem" :navigate-view-content="navigateViewContent" :load-children="loadChildren"/>
<ViewFileTreeItem v-for="childItem in children" :key="childItem.entryName" :item="childItem" :store="store"/>
</div>
</template>

<style scoped>
.sub-items {
display: flex;
Expand Down
44 changes: 44 additions & 0 deletions web_src/js/components/ViewFileTreeStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {reactive} from 'vue';
import {GET} from '../modules/fetch.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import {createElementFromHTML} from '../utils/dom.ts';

export function createViewFileTreeStore(props: { repoLink: string, treePath: string, currentRefNameSubURL: string}) {
const store = reactive({
rootFiles: [],
selectedItem: props.treePath,

async loadChildren(treePath: string, subPath: string = '') {
const response = await GET(`${props.repoLink}/tree-view/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}?sub_path=${encodeURIComponent(subPath)}`);
const json = await response.json();
const poolSvgs = [];
for (const [svgId, svgContent] of Object.entries(json.renderedIconPool ?? {})) {
if (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent);
}
if (poolSvgs.length) {
const svgContainer = createElementFromHTML('<div class="global-svg-icon-pool tw-hidden"></div>');
svgContainer.innerHTML = poolSvgs.join('');
document.body.append(svgContainer);
}
return json.fileTreeNodes ?? null;
},

async loadViewContent(url: string) {
url = url.includes('?') ? url.replace('?', '?only_content=true') : `${url}?only_content=true`;
const response = await GET(url);
document.querySelector('.repo-view-content').innerHTML = await response.text();
},

async navigateTreeView(treePath: string) {
const url = store.buildTreePathWebUrl(treePath);
window.history.pushState({treePath, url}, null, url);
store.selectedItem = treePath;
await store.loadViewContent(url);
},

buildTreePathWebUrl(treePath: string) {
return `${props.repoLink}/src/${props.currentRefNameSubURL}/${pathEscapeSegments(treePath)}`;
},
});
return store;
}
5 changes: 5 additions & 0 deletions web_src/js/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,3 +369,8 @@ export function addDelegatedEventListener<T extends HTMLElement, E extends Event
listener(elem as T, e as E);
}, options);
}

/** Returns whether a click event is a left-click without any modifiers held */
export function isPlainClick(e: MouseEvent) {
return e.button === 0 && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey;
}