chore: merge origin/main with local test additions
All checks were successful
CI / verify (push) Successful in 10m1s

Resolves conflict in repo.spec.ts by combining getRepoPermission
tests from main with the getFiles and queryFileContent tests from
the local test pass. Adds getRepoPermission to the userRepo.store
spec mocks so the new permission probe added upstream doesn't break
the store tests.
This commit is contained in:
Julien Calixte
2026-06-06 22:17:10 +02:00
17 changed files with 522 additions and 50 deletions

View File

@@ -35,7 +35,8 @@ export const useCheckboxCommit = ({
initialContent,
initialSha,
containerSelector,
debounceMs = 1000
debounceMs = 1000,
enabled = true
}: {
user: string
repo: string
@@ -44,6 +45,7 @@ export const useCheckboxCommit = ({
initialSha: Ref<string> | string
containerSelector: string
debounceMs?: number
enabled?: Ref<boolean> | boolean
}) => {
const { updateFile } = useGitHubContent({ user, repo })
@@ -91,6 +93,8 @@ export const useCheckboxCommit = ({
const debouncedCommit = useDebounceFn(commitChanges, debounceMs)
const handleCheckboxChange = (event: Event) => {
if (!toValue(enabled)) return
const target = event.target as HTMLInputElement
if (target.tagName !== "INPUT" || target.type !== "checkbox") {
@@ -128,8 +132,16 @@ export const useCheckboxCommit = ({
const listenToCheckboxes = () => {
removeListeners()
const container = document.querySelector(containerSelector)
if (container) {
if (!container) return
if (toValue(enabled)) {
container.addEventListener("change", handleCheckboxChange)
} else {
container
.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
.forEach((checkbox) => {
checkbox.disabled = true
})
}
}

View File

@@ -37,14 +37,22 @@ export const useGitHubContent = ({
}
}
const putFile = async ({
content,
const putRaw = async ({
contentBase64,
path,
sha
sha,
message,
successMessage,
conflictMessage,
failureMessage
}: {
content: string
contentBase64: string
path: string
sha?: string
message: string
successMessage: string
conflictMessage: string
failureMessage: string
}): Promise<{ sha: string | null; conflict: boolean }> => {
try {
const octokit = await getOctokit()
@@ -55,28 +63,63 @@ export const useGitHubContent = ({
owner: user,
repo,
path,
message: `Updating ${path} from Remanso`,
content: encodeUTF8ToBase64(content),
message,
content: contentBase64,
sha
}
)
confirmMessage("✅ Note saved")
confirmMessage(successMessage)
return { sha: response?.data.content?.sha ?? null, conflict: false }
} catch (error) {
const status = (error as { status?: number })?.status
if (status && isConflictStatus(status)) {
errorMessage("⚠ Conflict: this note changed on GitHub")
errorMessage(conflictMessage)
console.warn(error)
return { sha: null, conflict: true }
}
errorMessage("❌ Note could not be saved")
errorMessage(failureMessage)
console.warn(error)
return { sha: null, conflict: false }
}
}
const putFile = async ({
content,
path,
sha
}: {
content: string
path: string
sha?: string
}): Promise<{ sha: string | null; conflict: boolean }> =>
putRaw({
contentBase64: encodeUTF8ToBase64(content),
path,
sha,
message: `Updating ${path} from Remanso`,
successMessage: "✅ Note saved",
conflictMessage: "⚠ Conflict: this note changed on GitHub",
failureMessage: "❌ Note could not be saved"
})
const uploadBinaryFile = async ({
base64,
path
}: {
base64: string
path: string
}): Promise<{ sha: string | null; conflict: boolean }> =>
putRaw({
contentBase64: base64,
path,
message: `Uploading ${path} from Remanso`,
successMessage: "✅ Image uploaded",
conflictMessage: "⚠ A file already exists at this path on GitHub",
failureMessage: "❌ Image could not be uploaded"
})
return {
fetchLatestSha,
updateFile: async (props: {
@@ -85,6 +128,7 @@ export const useGitHubContent = ({
sha: string
}) => putFile(props),
createFile: async (props: { content: string; path: string }) =>
putFile(props)
putFile(props),
uploadBinaryFile
}
}

View File

@@ -0,0 +1,104 @@
import { Ref, toValue } from "vue"
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { errorMessage } from "@/utils/notif"
import { uniqueFilename } from "@/utils/uniqueFilename"
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
const bytes = new Uint8Array(buffer)
const chunkSize = 0x8000
let binary = ""
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize)
binary += String.fromCharCode(...chunk)
}
return btoa(binary)
}
const splitPath = (fullPath: string): { directory: string; filename: string } => {
const lastSlash = fullPath.lastIndexOf("/")
if (lastSlash === -1) return { directory: "", filename: fullPath }
return {
directory: fullPath.slice(0, lastSlash),
filename: fullPath.slice(lastSlash + 1)
}
}
const stripMarkdownExtension = (filename: string): string =>
filename.replace(/\.(md|markdown|mdx)$/i, "")
const extractExtension = (filename: string): string => {
const dot = filename.lastIndexOf(".")
if (dot <= 0) return ".png"
return filename.slice(dot).toLowerCase()
}
export const useImageUpload = ({
user,
repo,
notePath
}: {
user: string
repo: string
notePath: Ref<string | undefined> | string | undefined
}) => {
const store = useUserRepoStore()
const { uploadBinaryFile } = useGitHubContent({ user, repo })
const uploadImage = async (
file: File
): Promise<{ filename: string } | null> => {
const currentNotePath = toValue(notePath)
if (!currentNotePath) {
errorMessage("❌ Image upload failed")
return null
}
try {
const { directory, filename: noteFilename } = splitPath(currentNotePath)
const basename = stripMarkdownExtension(noteFilename)
const extension = extractExtension(file.name)
const existingPaths = store.files
.map((f) => f.path)
.filter((p): p is string => typeof p === "string")
const filename = uniqueFilename({
basename,
extension,
existingPaths,
directory
})
const targetPath = directory ? `${directory}/${filename}` : filename
const buffer = await file.arrayBuffer()
const base64 = arrayBufferToBase64(buffer)
const { sha, conflict } = await uploadBinaryFile({
base64,
path: targetPath
})
if (conflict || !sha) {
return null
}
store.registerUploadedFile({
path: targetPath,
sha,
type: "blob",
size: file.size
})
return { filename }
} catch (error) {
console.warn("image upload failed", error)
errorMessage("❌ Image upload failed")
return null
}
}
return { uploadImage }
}

View File

@@ -77,14 +77,17 @@ export const useNoteFreshness = ({
return { sha: result.sha, failureStatus: null }
}
const pullLatest = async (): Promise<string | null> => {
if (!path.value) return null
const pullLatest = async (): Promise<{
raw: string | null
failureStatus: FreshnessStatus | null
}> => {
if (!path.value) return { raw: null, failureStatus: null }
const usedCachedSha = latestSha.value !== null
const { sha: remoteSha, failureStatus } = await resolveRemoteSha(path.value)
if (!remoteSha) {
console.warn("pullLatest: could not resolve remote sha", { path: path.value })
if (failureStatus) status.value = failureStatus
return null
return { raw: null, failureStatus }
}
const fileContent = await queryFileContent(user, repo, remoteSha)
if (!fileContent) {
@@ -96,7 +99,7 @@ export const useNoteFreshness = ({
// Cached SHA may be stale — clear so the next click re-resolves it.
if (usedCachedSha) latestSha.value = null
status.value = "offline"
return null
return { raw: null, failureStatus: "offline" }
}
const { saveCacheNote } = prepareNoteCache(sha.value, path.value)
await saveCacheNote(fileContent, {
@@ -108,7 +111,7 @@ export const useNoteFreshness = ({
lastCheckedAt.value = new Date()
status.value = "verified"
const { getRawContent } = markdownBuilder(sha.value)
return getRawContent(fileContent)
return { raw: getRawContent(fileContent), failureStatus: null }
}
return {