Skip to content
Draft
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
1 change: 1 addition & 0 deletions build/frontend-legacy/webpack.modules.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module.exports = {
login: path.join(__dirname, 'core/src', 'login.js'),
login_flow: path.join(__dirname, 'core/src', 'login-flow.ts'),
main: path.join(__dirname, 'core/src', 'main.js'),
appmenu: path.join(__dirname, 'core/src/appmenu', 'main.ts'),
maintenance: path.join(__dirname, 'core/src', 'maintenance.js'),
'public-page-menu': path.resolve(__dirname, 'core/src', 'public-page-menu.ts'),
'public-page-user-menu': path.resolve(__dirname, 'core/src', 'public-page-user-menu.ts'),
Expand Down
File renamed without changes.
File renamed without changes.
37 changes: 37 additions & 0 deletions core/src/appmenu/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* Standalone entry for the waffle launcher (AppMenu). Mounts independently of
* core-main so the app grid lives in its own chunk.
*/
import Vue from 'vue'
import AppMenu from './AppMenu.vue'

interface AppMenuInstance {
setNavigationCounter(id: string, counter: number): void
}

declare global {

var OC: {
setNavigationCounter?: (id: string, counter: number) => void
}
}

function mount(): void {
const container = document.getElementById('header-start__appmenu')
if (!container) {
// No container on this layout (e.g. public pages). Nothing to mount.
return
}
const AppMenuApp = Vue.extend(AppMenu)
const instance = new AppMenuApp({}).$mount(container) as unknown as AppMenuInstance

globalThis.OC = globalThis.OC ?? {}
globalThis.OC.setNavigationCounter = (id, counter) => {
instance.setNavigationCounter(id, counter)
}
}

mount()
35 changes: 0 additions & 35 deletions core/src/components/MainMenu.js

This file was deleted.

2 changes: 0 additions & 2 deletions core/src/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import { getLocale } from '@nextcloud/l10n'
import moment from 'moment'
import { setUp as setUpContactsMenu } from './components/ContactsMenu.js'
import { setUp as setUpMainMenu } from './components/MainMenu.js'
import { setUp as setUpUserMenu } from './components/UserMenu.js'
import { initSessionHeartBeat } from './session-heartbeat.ts'
import { initFallbackClipboardAPI } from './utils/ClipboardFallback.ts'
Expand Down Expand Up @@ -46,7 +45,6 @@ export function initCore() {

initSessionHeartBeat()

setUpMainMenu()
setUpUserMenu()
setUpContactsMenu()
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ vi.mock('@nextcloud/l10n', () => ({
},
}))

import AppItem from '../../components/AppItem.vue'
import AppItem from '../../appmenu/AppItem.vue'

function makeApp(overrides: Partial<INavigationEntry> = {}): INavigationEntry {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ function eightApps(activeIndex: number = -1): INavigationEntry[] {
// Import AFTER mocks are registered. Static `import` would hoist above
// vi.mock() and break the wiring; dynamic import in beforeAll/await is the
// idiomatic Vitest workaround when you need to control mock state per test.
import type AppMenuModule from '../../components/AppMenu.vue'
import type AppMenuModule from '../../appmenu/AppMenu.vue'
let AppMenu: typeof AppMenuModule

beforeEach(async () => {
Expand All @@ -88,7 +88,7 @@ beforeEach(async () => {
}
initialState.loadState.mockImplementation((_app: string, key: string, fallback: unknown) => key === 'apps' ? fakeApps() : fallback)
auth.getCurrentUser.mockReturnValue({ isAdmin: false })
AppMenu = (await import('../../components/AppMenu.vue')).default
AppMenu = (await import('../../appmenu/AppMenu.vue')).default
})

afterEach(() => {
Expand Down
68 changes: 68 additions & 0 deletions core/src/tests/appmenu/main.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@nextcloud/initial-state', () => ({
loadState: () => [],
}))
vi.mock('@nextcloud/auth', () => ({
getCurrentUser: () => ({ isAdmin: false }),
}))
vi.mock('@nextcloud/event-bus', () => ({
subscribe: () => undefined,
unsubscribe: () => undefined,
}))
vi.mock('@nextcloud/l10n', () => ({
isRTL: () => false,
n: (_app: string, singular: string) => singular,
t: (_app: string, text: string) => text,
}))
vi.mock('@nextcloud/router', () => ({
generateUrl: (url: string) => url,
imagePath: (_app: string, file: string) => `/img/${file}`,
}))

declare global {
// eslint-disable-next-line no-var
var OC: { setNavigationCounter?: (id: string, count: number) => void }
}

// The id the bootstrap mounts into (must match main.ts).
function addContainer(): void {
const container = document.createElement('nav')
container.id = 'header-start__appmenu'
document.body.appendChild(container)
}

describe('appmenu/main', () => {
beforeEach(() => {
document.body.innerHTML = ''
globalThis.OC = {}
vi.resetModules()
})

it('mounts AppMenu when the container is present', async () => {
addContainer()

await import('../../appmenu/main.ts')

// Vue 2 $mount replaces the container with AppMenu's root <nav class="app-menu">.
expect(document.querySelector('.app-menu')).not.toBeNull()
})

it('no-ops when the container is missing', async () => {
await import('../../appmenu/main.ts')

expect(document.body.children.length).toBe(0)
})

it('exposes OC.setNavigationCounter as a callable function', async () => {
addContainer()

await import('../../appmenu/main.ts')

expect(typeof globalThis.OC.setNavigationCounter).toBe('function')
})
})
32 changes: 21 additions & 11 deletions lib/private/TemplateLayout.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@
use OCP\Template\ITemplateManager;
use OCP\Util;

class TemplateLayout {
class TemplateLayout
{
private string $versionHash = '';
/** @var string[] */
private array $cacheBusterCache = [];
Expand All @@ -59,10 +60,10 @@ public function __construct(
private ITemplateManager $templateManager,
private ServerVersion $serverVersion,
private IRequest $request,
) {
}
) {}

public function getPageTemplate(string $renderAs, string $appId): ITemplate {
public function getPageTemplate(string $renderAs, string $appId): ITemplate
{
// Add fallback theming variables if not rendered as user
if ($renderAs !== TemplateResponse::RENDER_AS_USER) {
// TODO cache generated default theme if enabled for fallback if server is erroring ?
Expand Down Expand Up @@ -92,6 +93,8 @@ public function getPageTemplate(string $renderAs, string $appId): ITemplate {
Util::addScript('core', 'unified-search', 'core');
}

Util::addScript('core', 'appmenu', 'core');

// Set logo link target
$logoUrl = $this->config->getSystemValueString('logo_url', '');
$page->assign('logoUrl', $logoUrl);
Expand Down Expand Up @@ -267,7 +270,8 @@ public function getPageTemplate(string $renderAs, string $appId): ITemplate {

// Do not initialise scss appdata until we have a fully installed instance
// Do not load scss for update, errors, installation or login page
if ($this->config->getSystemValueBool('installed', false)
if (
$this->config->getSystemValueBool('installed', false)
&& !Util::needUpgrade()
&& $pathInfo !== ''
&& !preg_match('/^\/login/', $pathInfo)
Expand Down Expand Up @@ -315,7 +319,8 @@ public function getPageTemplate(string $renderAs, string $appId): ITemplate {
return $page;
}

protected function getVersionHashSuffix(string $path = '', string $file = ''): string {
protected function getVersionHashSuffix(string $path = '', string $file = ''): string
{
if ($this->config->getSystemValueBool('debug', false)) {
// allows chrome workspace mapping in debug mode
return '';
Expand Down Expand Up @@ -346,7 +351,8 @@ protected function getVersionHashSuffix(string $path = '', string $file = ''): s
return '?v=' . $hash . $themingSuffix;
}

private function getVersionHashByPath(string $path): string|false {
private function getVersionHashByPath(string $path): string|false
{
if (array_key_exists($path, $this->cacheBusterCache) === false) {
// Not yet cached, so lets find the cache buster string
$appId = $this->getAppNamefromPath($path);
Expand All @@ -373,15 +379,17 @@ private function getVersionHashByPath(string $path): string|false {
return $this->cacheBusterCache[$path];
}

private function findStylesheetFiles(array $styles): array {
private function findStylesheetFiles(array $styles): array
{
if ($this->cssLocator === null) {
$this->cssLocator = Server::get(CSSResourceLocator::class);
}
$this->cssLocator->find($styles);
return $this->cssLocator->getResources();
}

public function getAppNamefromPath(string $path): string|false {
public function getAppNamefromPath(string $path): string|false
{
if ($path !== '') {
$pathParts = explode('/', $path);
if ($pathParts[0] === 'css') {
Expand All @@ -395,7 +403,8 @@ public function getAppNamefromPath(string $path): string|false {
return false;
}

private function findJavascriptFiles(array $scripts): array {
private function findJavascriptFiles(array $scripts): array
{
if ($this->jsLocator === null) {
$this->jsLocator = Server::get(JSResourceLocator::class);
}
Expand All @@ -409,7 +418,8 @@ private function findJavascriptFiles(array $scripts): array {
* @return string Relative path
* @throws \Exception If $filePath is not under \OC::$SERVERROOT
*/
public static function convertToRelativePath(string $filePath) {
public static function convertToRelativePath(string $filePath)
{
$relativePath = explode(\OC::$SERVERROOT, $filePath);
if (count($relativePath) !== 2) {
throw new \Exception('$filePath is not under the \OC::$SERVERROOT');
Expand Down
Loading