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

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
92 changes: 29 additions & 63 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,89 +16,52 @@ 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"/>
</div>
Expand All @@ -105,12 +70,13 @@ const doGotoSubModule = () => {
<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;
}