Skip to content
Merged
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
106 changes: 106 additions & 0 deletions packages/web-app-files/src/components/FilesList/ListHeader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<template>
<div>
<div
v-if="loadReadmeContentTask.isRunning || !loadReadmeContentTask.last"
class="flex justify-center"
>
<oc-spinner size="large" class="my-4" :aria-label="$gettext('Loading README content')" />
</div>
<div
v-else
ref="markdownContainerRef"
class="markdown-container flex min-h-0 [&.collapsed]:max-h-[300px] [&.collapsed]:overflow-hidden"
:class="{
collapsed: markdownCollapsed,
'mask-linear-[180deg,black,80%,transparent]': markdownCollapsed && showMarkdownCollapse
}"
>
<text-editor class="w-full" is-read-only :current-content="markdownContent" />
</div>
<div v-if="showMarkdownCollapse && markdownContent" class="markdown-collapse text-center mt-2">
<oc-button appearance="raw" no-hover @click="toggleMarkdownCollapsed">
<span>{{ toggleMarkdownCollapsedText }}</span>
</oc-button>
</div>
</div>
</template>

<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, unref, useTemplateRef } from 'vue'
import { Resource, SpaceResource } from '@opencloud-eu/web-client'
import { TextEditor, useClientService } from '@opencloud-eu/web-pkg'
import { useTask } from 'vue-concurrency'
import { useGettext } from 'vue3-gettext'
const { space, readmeFile } = defineProps<{
space: SpaceResource
readmeFile: Resource
}>()
const { $gettext } = useGettext()
const clientService = useClientService()
const { getFileContents } = clientService.webdav
const markdownContainerRef = useTemplateRef('markdownContainerRef')
const markdownContent = ref('')
const markdownCollapsed = ref(true)
const showMarkdownCollapse = ref(false)
const toggleMarkdownCollapsedText = computed(() => {
return unref(markdownCollapsed) ? $gettext('Show more') : $gettext('Show less')
})
const toggleMarkdownCollapsed = () => {
markdownCollapsed.value = !unref(markdownCollapsed)
}
const onMarkdownResize = () => {
if (!unref(markdownContainerRef)) {
return
}
unref(markdownContainerRef).classList.remove('collapsed')
const markdownContainerHeight = unref(markdownContainerRef).offsetHeight
if (markdownContainerHeight < 300) {
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The magic number 300 appears twice (lines 12 and 63) for the collapse threshold. Extract this into a named constant to improve maintainability and ensure consistency.

Copilot uses AI. Check for mistakes.
showMarkdownCollapse.value = false
return
}
showMarkdownCollapse.value = true
if (unref(markdownCollapsed)) {
unref(markdownContainerRef).classList.add('collapsed')
}
}
const markdownResizeObserver = new ResizeObserver(onMarkdownResize)
const observeMarkdownContainerResize = () => {
if (!markdownResizeObserver || !unref(markdownContainerRef)) {
return
}
markdownResizeObserver.unobserve(unref(markdownContainerRef))
markdownResizeObserver.observe(unref(markdownContainerRef))
}
const unobserveMarkdownContainerResize = () => {
if (!markdownResizeObserver || !unref(markdownContainerRef)) {
return
}
markdownResizeObserver.unobserve(unref(markdownContainerRef))
}
const loadReadmeContentTask = useTask(function* (signal) {
try {
const { body } = yield getFileContents(space, { fileId: unref(readmeFile).id }, { signal })
markdownContent.value = body || ''
} catch (e) {
console.error('failed to load README.md content', e)
}
})
onMounted(async () => {
await loadReadmeContentTask.perform()
await nextTick()
observeMarkdownContainerResize()
})
onBeforeUnmount(() => {
unobserveMarkdownContainerResize()
})
</script>
19 changes: 17 additions & 2 deletions packages/web-app-files/src/views/spaces/GenericSpace.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@
</template>
</no-content-message>
<template v-else>
<list-header
v-if="readmeFile && !hasSpaceHeader"
:space="space"
:readme-file="readmeFile"
class="mx-4 my-2"
/>
<resource-details
v-if="displayResourceAsSingleResource"
:single-resource="paginatedResources[0]"
Expand Down Expand Up @@ -182,6 +188,7 @@ import {
} from '../../composables/keyboardActions'
import { storeToRefs } from 'pinia'
import { folderViewsFolderExtensionPoint } from '../../extensionPoints'
import ListHeader from '../../components/FilesList/ListHeader.vue'

export default defineComponent({
name: 'GenericSpace',
Expand All @@ -202,7 +209,8 @@ export default defineComponent({
ResourceTable,
ResourceTiles,
SpaceHeader,
WhitespaceContextMenu
WhitespaceContextMenu,
ListHeader
},
props: {
space: {
Expand Down Expand Up @@ -460,6 +468,12 @@ export default defineComponent({
}
}

const readmeFile = computed(() => {
return unref(resourcesViewDefaults.storeItems).find(
(item) => item.name.toLowerCase() === 'readme.md'
)
})

onMounted(() => {
performLoaderTask(false)
loadResourcesEventToken = eventBus.subscribe(
Expand Down Expand Up @@ -570,7 +584,8 @@ export default defineComponent({
totalResourcesCount,
areHiddenFilesShown,
fileDropped,
loadPreview
loadPreview,
readmeFile
}
},

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import ListHeader from '../../../../src/components/FilesList/ListHeader.vue'
import { defaultComponentMocks, defaultPlugins, shallowMount } from '@opencloud-eu/web-test-helpers'
import { mock } from 'vitest-mock-extended'
import { Resource, SpaceResource } from '@opencloud-eu/web-client'
import { flushPromises } from '@vue/test-utils'

describe('ListHeader', () => {
it('renders a spinner when loading', () => {
const wrapper = getWrapper()
expect(wrapper.find('oc-spinner-stub').exists()).toBeTruthy()
})
it('renders a markdown container when README content is loaded', async () => {
const wrapper = getWrapper()
await flushPromises()
expect(wrapper.find('.markdown-container').exists()).toBeTruthy()
})
})

function getWrapper() {
const mocks = {
...defaultComponentMocks()
}

mocks.$clientService.webdav.getFileContents.mockResolvedValueOnce({
body: 'Sample README content'
})

return shallowMount(ListHeader, {
props: {
space: mock<SpaceResource>(),
readmeFile: mock<Resource>()
},
global: {
mocks,
plugins: [...defaultPlugins()],
provide: mocks
}
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ describe('GenericSpace view', () => {
expect(wrapper.find('.no-content-message').exists()).toBeTruthy()
})
it('shows the files table when files are available', () => {
const { wrapper } = getMountedWrapper({ files: [mock<Resource>()] })
const { wrapper } = getMountedWrapper({ files: [mock<Resource>({ name: 'file.txt' })] })
expect(wrapper.find('.no-content-message').exists()).toBeFalsy()
expect(wrapper.find('resource-table-stub').exists()).toBeTruthy()
})
Expand Down Expand Up @@ -175,7 +175,7 @@ describe('GenericSpace view', () => {
it('renders the ResourceDetails component if no currentFolder id is present', () => {
const { wrapper } = getMountedWrapper({
currentFolder: mock<Resource>({ fileId: '' }),
files: [mock<Resource>({ isFolder: false })],
files: [mock<Resource>({ name: 'file.txt', isFolder: false })],
runningOnEos: true
})
expect(wrapper.find('resource-details-stub').exists()).toBeTruthy()
Expand All @@ -187,7 +187,7 @@ describe('GenericSpace view', () => {
...mock<Resource>(),
path
},
files: [{ ...mock<Resource>(), path }],
files: [{ ...mock<Resource>({ name: 'file.txt' }), path }],
runningOnEos: true
})
expect(wrapper.find('resource-details-stub').exists()).toBeTruthy()
Expand All @@ -199,7 +199,7 @@ describe('GenericSpace view', () => {
currentFolder: {
...mock<Resource>()
},
files: [{ ...mock<Resource>(), isFolder: false }],
files: [{ ...mock<Resource>({ name: 'file.txt' }), isFolder: false }],
space: mock<SpaceResource>({
id: '1',
getDriveAliasAndItem: vi.fn(),
Expand Down Expand Up @@ -230,6 +230,23 @@ describe('GenericSpace view', () => {
expect(wrapper.find(selectors.actionsCreateAndUpload).exists()).toBe(true)
})
})
describe('list header', () => {
it('renders when a readme file is present', () => {
const { wrapper } = getMountedWrapper({ files: [mock<Resource>({ name: 'readme.md' })] })
expect(wrapper.find('list-header-stub').exists()).toBeTruthy()
})
it('does not render when a readme file is not present', () => {
const { wrapper } = getMountedWrapper({ files: [mock<Resource>({ name: 'file.txt' })] })
expect(wrapper.find('list-header-stub').exists()).toBeFalsy()
})
it('does not render on the frontpage of a space', () => {
const { wrapper } = getMountedWrapper({
files: [mock<Resource>({ name: 'readme.md' })],
space: mock<SpaceResource>({ driveType: 'project' })
})
expect(wrapper.find('list-header-stub').exists()).toBeFalsy()
})
})
})

function getMountedWrapper({
Expand Down Expand Up @@ -269,7 +286,8 @@ function getMountedWrapper({

const resourcesViewDetailsMock = useResourcesViewDefaultsMock({
paginatedResources: ref(files),
areResourcesLoading: ref(loading)
areResourcesLoading: ref(loading),
storeItems: ref(files)
})
vi.mocked(useResourcesViewDefaults).mockImplementation(() => resourcesViewDetailsMock)
vi.mocked(useBreadcrumbsFromPath).mockImplementation(() =>
Expand All @@ -296,7 +314,13 @@ function getMountedWrapper({
plugins,
mocks: defaultMocks,
provide: defaultMocks,
stubs: { ...defaultStubs, 'resource-details': true, portal: true, ...stubs }
stubs: {
...defaultStubs,
'resource-details': true,
portal: true,
...stubs,
ListHeader: true
}
}
})
}
Expand Down
38 changes: 22 additions & 16 deletions packages/web-pkg/src/components/TextEditor/TextEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,22 @@
id="text-editor-container"
class="h-full [&_.md-editor-preview]:!font-(family-name:--oc-font-family)"
>
<md-preview
v-if="isReadOnly"
id="text-editor-preview-component"
:model-value="currentContent"
no-katex
no-mermaid
no-prettier
no-upload-img
no-highlight
no-echarts
:language="languages[language.current] || 'en-US'"
:theme="theme"
auto-focus
read-only
/>
<article v-if="isReadOnly">
<md-preview
id="text-editor-preview-component"
:model-value="currentContent"
no-katex
no-mermaid
no-prettier
no-upload-img
no-highlight
no-echarts
:language="languages[language.current] || 'en-US'"
:theme="theme"
auto-focus
read-only
/>
</article>
<md-editor
v-else
id="text-editor-component"
Expand All @@ -43,7 +44,7 @@
@on-click="showLineNumbers = !showLineNumbers"
>
<oc-icon
class="!flex items-center justify-center w-[24px] h-[24px]"
class="!flex items-center justify-center size-6"
size="small"
name="hashtag"
fill-type="none"
Expand Down Expand Up @@ -264,6 +265,11 @@ export default defineComponent({
background-color: transparent;
}

#text-editor-component-preview > :first-child,
#text-editor-preview-component-preview > :first-child {
margin-top: 0 !important;
}

// overwrite md-editor styles
.md-editor {
height: 100%;
Expand Down