Skip to content

Commit 3a2970c

Browse files
committed
enh(directEditing): allow navigating to another file
`OCA.Text.openDirectEditing(url)` will create a new session via the new OCS API endpoint `/apps/text/directEditing/edit` which opens the token from the given url and returns the session. This session then replaces the `initialSession` in the editor. The editor reloads. Signed-off-by: Max <max@nextcloud.com>
1 parent 7547c1a commit 3a2970c

12 files changed

Lines changed: 239 additions & 9 deletions

File tree

‎appinfo/routes.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,7 @@
6969
['name' => 'Workspace#publicFolder', 'url' => '/public/workspace', 'verb' => 'GET'],
7070
/** @see Controller\WorkspaceController::direct() */
7171
['name' => 'Workspace#direct', 'url' => '/workspace/direct', 'verb' => 'POST'],
72+
/** @see Controller\DirectEditingController::edit() */
73+
['name' => 'DirectEditing#edit', 'url' => '/directEditing/edit', 'verb' => 'POST'],
7274
]
7375
];

‎composer/composer/autoload_classmap.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
'OCA\\Text\\Context\\UnauthorizedFileContext' => $baseDir . '/../lib/Context/UnauthorizedFileContext.php',
2222
'OCA\\Text\\Controller\\AiController' => $baseDir . '/../lib/Controller/AiController.php',
2323
'OCA\\Text\\Controller\\AttachmentController' => $baseDir . '/../lib/Controller/AttachmentController.php',
24+
'OCA\\Text\\Controller\\DirectEditingController' => $baseDir . '/../lib/Controller/DirectEditingController.php',
2425
'OCA\\Text\\Controller\\ISessionAwareController' => $baseDir . '/../lib/Controller/ISessionAwareController.php',
2526
'OCA\\Text\\Controller\\NavigationController' => $baseDir . '/../lib/Controller/NavigationController.php',
2627
'OCA\\Text\\Controller\\PublicSessionController' => $baseDir . '/../lib/Controller/PublicSessionController.php',

‎composer/composer/autoload_static.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class ComposerStaticInitText
3636
'OCA\\Text\\Context\\UnauthorizedFileContext' => __DIR__ . '/..' . '/../lib/Context/UnauthorizedFileContext.php',
3737
'OCA\\Text\\Controller\\AiController' => __DIR__ . '/..' . '/../lib/Controller/AiController.php',
3838
'OCA\\Text\\Controller\\AttachmentController' => __DIR__ . '/..' . '/../lib/Controller/AttachmentController.php',
39+
'OCA\\Text\\Controller\\DirectEditingController' => __DIR__ . '/..' . '/../lib/Controller/DirectEditingController.php',
3940
'OCA\\Text\\Controller\\ISessionAwareController' => __DIR__ . '/..' . '/../lib/Controller/ISessionAwareController.php',
4041
'OCA\\Text\\Controller\\NavigationController' => __DIR__ . '/..' . '/../lib/Controller/NavigationController.php',
4142
'OCA\\Text\\Controller\\PublicSessionController' => __DIR__ . '/..' . '/../lib/Controller/PublicSessionController.php',
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Text\Controller;
11+
12+
use OCA\Text\Context\FileContextFactory;
13+
use OCA\Text\Service\ApiService;
14+
use OCP\AppFramework\Http;
15+
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
16+
use OCP\AppFramework\Http\Attribute\PublicPage;
17+
use OCP\AppFramework\Http\DataResponse;
18+
use OCP\AppFramework\OCSController;
19+
use OCP\DirectEditing\IManager;
20+
use OCP\Files\InvalidPathException;
21+
use OCP\Files\NotFoundException;
22+
use OCP\Files\NotPermittedException;
23+
use OCP\IRequest;
24+
use OCP\IUserManager;
25+
use OCP\IUserSession;
26+
use Psr\Log\LoggerInterface;
27+
28+
class DirectEditingController extends OCSController {
29+
public function __construct(
30+
string $appName,
31+
IRequest $request,
32+
private readonly IManager $directEditingManager,
33+
private readonly IUserSession $userSession,
34+
private readonly ApiService $apiService,
35+
private readonly IUserManager $userManager,
36+
private readonly FileContextFactory $fileContextFactory,
37+
private readonly LoggerInterface $logger,
38+
) {
39+
parent::__construct($appName, $request);
40+
}
41+
42+
/**
43+
* Open a direct editing token and create an editing session.
44+
*
45+
* This allows navigating from one direct editing file to the next without reloading the page.
46+
*
47+
* Returns 200 with file metadata and session data if the token is found.
48+
*
49+
* @param string $url Direct editing url as provided by the server
50+
*/
51+
#[NoAdminRequired]
52+
#[PublicPage]
53+
public function edit(string $token): DataResponse {
54+
$token = $this->directEditingManager->getToken($token);
55+
try {
56+
$token->useTokenScope();
57+
$user = $this->userManager->get($token->getUser());
58+
if ($user === null) {
59+
throw new NotFoundException();
60+
}
61+
$file = $token->getFile();
62+
$context = $this->fileContextFactory->build($user, 'file', $file->getId(), $file);
63+
$session = $this->apiService->create($context, null);
64+
$this->logger->debug('Started direct editing session.', ['token' => $token->getToken()]);
65+
return new DataResponse([
66+
'fileId' => $token->getFile()->getId(),
67+
'mimetype' => $token->getFile()->getMimeType(),
68+
'session' => $session->getData(),
69+
]);
70+
} catch (InvalidPathException|NotFoundException|NotPermittedException) {
71+
return new DataResponse([], Http::STATUS_NOT_FOUND);
72+
} finally {
73+
$this->revertTokenScope();
74+
}
75+
}
76+
77+
private function revertTokenScope(): void {
78+
$this->userSession->setUser(null);
79+
}
80+
81+
}

‎lib/Middleware/SessionMiddleware.php‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
use OCP\IRequest;
3131
use OCP\IUserManager;
3232
use OCP\IUserSession;
33+
use Psr\Log\LoggerInterface;
3334
use ReflectionException;
3435

3536
class SessionMiddleware extends Middleware {
@@ -43,6 +44,7 @@ public function __construct(
4344
private readonly IL10N $l10n,
4445
private readonly IUserManager $userManager,
4546
private readonly FileService $fileService,
47+
private readonly LoggerInterface $logger,
4648
) {
4749
}
4850

@@ -155,6 +157,7 @@ private function getDocument(string $documentId): ?Document {
155157
}
156158

157159
public function afterException($controller, $methodName, \Exception $exception): JSONResponse|Response {
160+
$this->logger->debug('After exception', ['exception' => $exception]);
158161
if ($exception instanceof InvalidDocumentBaseVersionEtagException) {
159162
return new JSONResponse(['error' => $this->l10n->t('Editing session has expired. Please reload the page.')], Http::STATUS_PRECONDITION_FAILED);
160163
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { expect, mergeTests } from '@playwright/test'
7+
import { test as directEditFileTest } from '../support/fixtures/direct-edit-file.ts'
8+
import { test as editorTest } from '../support/fixtures/editor.ts'
9+
import { test as randomUserTest } from '../support/fixtures/random-user.ts'
10+
11+
const test = mergeTests(editorTest, randomUserTest, directEditFileTest)
12+
13+
test.use({
14+
fileName: 'initial.md',
15+
fileContent: '# Initial File',
16+
})
17+
18+
test.describe('Direct editing', () => {
19+
test('opens once', async ({ editor, open }) => {
20+
await open()
21+
await expect(editor.getHeading({ name: 'Initial File' })).toBeVisible()
22+
})
23+
24+
test('opens next file', async ({ editor, open, owner, page }) => {
25+
const secondFile = await owner.uploadFile({ name: 'second.md', content: '# Second file' })
26+
const { url } = await secondFile.createDirectEditingLink()
27+
await open()
28+
await expect(editor.getHeading({ name: 'Initial File' })).toBeVisible()
29+
await page.evaluate((url) => {
30+
window.OCA.Text.openDirectEditing(url)
31+
}, url)
32+
await expect(editor.getHeading({ name: 'Second File' })).toBeVisible()
33+
})
34+
})

‎playwright/support/fixtures/Node.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,23 @@ export class Node {
7474
return { token, id }
7575
}
7676

77+
async createDirectEditingLink(): Promise<{ url: string }> {
78+
const path = `/${this.name}`
79+
const response = await this.page.request.post(
80+
'/ocs/v2.php/apps/files/api/v1/directEditing/open?format=json',
81+
{
82+
data: { path },
83+
headers: ocsHeaders,
84+
/*
85+
'OCS-ApiRequest': 'true',
86+
'Content-Type': 'application/x-www-form-urlencoded',
87+
*/
88+
},
89+
)
90+
const { ocs } = await response.json() as { ocs: { data: { url: string } } }
91+
return ocs.data
92+
}
93+
7794
async getContent() {
7895
const response = await this.page.request.get(`/remote.php/webdav/${this.name}`)
7996
return response.text()
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { expect } from '@playwright/test'
7+
import { test as base } from './shared-file.ts'
8+
9+
export interface DirectEditFileFixture {
10+
directEditing: { url: string }
11+
}
12+
13+
/**
14+
* This test fixture uploads the file to a user's root directory and shares it.
15+
*/
16+
export const test = base.extend<DirectEditFileFixture>({
17+
18+
directEditing: async ({ file }, use) => {
19+
const { url } = await file.createDirectEditingLink()
20+
await use({ url })
21+
},
22+
23+
open: async ({ page, directEditing }, use) => {
24+
const open = async () => {
25+
await page.goto(directEditing.url)
26+
await expect(page.getByRole('toolbar', { name: 'Formatting menu bar' }))
27+
.toBeVisible()
28+
}
29+
await use(open)
30+
},
31+
32+
})

‎src/apis/connect.ts‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55

66
import type { Connection } from '../types/Connection.ts'
77
import type { Document } from '../types/Document.ts'
8-
import type { GuestSession, Session } from '../types/Session.ts'
8+
import type { GuestSession, Session, UserSession } from '../types/Session.ts'
99

1010
import axios from '@nextcloud/axios'
11-
import { generateUrl } from '@nextcloud/router'
11+
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
1212

1313
export interface OpenContextParams {
1414
type: string
@@ -75,6 +75,28 @@ export async function openShare(params: OpenShareParams): Promise<{ connection:
7575
return { connection, data: response.data }
7676
}
7777

78+
interface DirectEditingState {
79+
session: UserSession
80+
fileId: number
81+
mimeType: string
82+
}
83+
84+
/**
85+
* Open a direct editing url to start editing the file in question
86+
*
87+
* Opens the direct editing token without loading a new html page.
88+
* The response can be used to load the file in the current page.
89+
*
90+
* @param params send with the request.
91+
* @param params.url the direct editing url as provided by the server.
92+
*/
93+
export async function openDirectEditing(params: { url: string }): Promise<DirectEditingState> {
94+
const url = generateOcsUrl('/apps/text/directEditing/edit')
95+
const token = params.url.split('/').at(-1)
96+
const response = await axios.post(url, { ...params, token })
97+
return response.data.ocs.data
98+
}
99+
78100
/**
79101
* Update the guest name
80102
*

‎src/components/EditorReloader.vue‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import type { ComponentInstance, ShallowRef } from 'vue'
2626
2727
import { nextTick, ref, useTemplateRef, watch } from 'vue'
2828
import CollaborativeEditor from './CollaborativeEditor.vue'
29+
import { logger } from '../helpers/logger.ts'
30+
2931
const props = defineProps(CollaborativeEditor.props)
3032
defineEmits(['focus', 'push:forbidden'])
3133
const reloading = ref(false)
@@ -37,6 +39,14 @@ watch(reloading, (val) => {
3739
}
3840
})
3941
42+
watch(
43+
() => props.initialSession,
44+
(cur, old) => {
45+
logger.debug('initialSession changed', { old, cur })
46+
reloading.value = true
47+
},
48+
)
49+
4050
const editorComponent = useTemplateRef('editor-component') as Readonly<ShallowRef<ComponentInstance<typeof CollaborativeEditor | null>>>
4151
defineExpose({
4252
get editor() {

0 commit comments

Comments
 (0)