beta/v0.4.1 release (#307)

This commit is contained in:
Graham Steffaniak 2025-01-25 19:31:40 -05:00 committed by GitHub
parent a30bfcf7d0
commit c168599c91
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 524 additions and 182 deletions

2
.gitignore vendored
View File

@ -6,6 +6,8 @@ _old
rice-box.go
.idea/
/backend/backend
/backend/filebrowser
/backend/filebrowser.exe
/backend/backend.exe
/frontend/dist
/frontend/pkg

View File

@ -2,6 +2,21 @@
All notable changes to this project will be documented in this file. For commit guidelines, please refer to [Standard Version](https://github.com/conventional-changelog/standard-version).
## v0.4.1-beta
**New Features**
- right-click actions are available on search. https://github.com/gtsteffaniak/filebrowser/issues/273
**Notes**
- delete prompt now lists all items that will be affected by delete
- Debug and logger output tweaks.
**Bugfixes**:
- calculating checksums errors.
- copy/move issues for some circumstances.
- The previous position wasn't returned when closing a preview window https://github.com/gtsteffaniak/filebrowser/issues/298
- fixed sources configuration mapping error (advanced `server.sources` config)
## v0.4.0-beta
**New Features**

View File

@ -130,7 +130,6 @@ Release Info : https://github.com/gtsteffaniak/filebrowser/releases/tag/%v
logger.Debug(fmt.Sprintf("Embeded frontend : %v", os.Getenv("FILEBROWSER_NO_EMBEDED") != "true"))
logger.Info(database)
logger.Info(fmt.Sprintf("Sources : %v", sources))
serverConfig := settings.Config.Server
swagInfo := docs.SwaggerInfo
swagInfo.BasePath = serverConfig.BaseURL

Binary file not shown.

View File

@ -5,6 +5,13 @@ server:
auth:
method: password
signup: false
frontend:
name: "Graham's Filebrowser"
disableDefaultLinks: true
externalLinks:
- text: "A playwright test"
url: "https://playwright.dev/"
title: "Playwright"
userDefaults:
darkMode: true
disableSettings: false

View File

@ -208,11 +208,11 @@ func MoveResource(source, realsrc, realdst string, isSrcDir bool) error {
index := GetIndex(source)
// refresh info for source and dest
err = index.RefreshFileInfo(FileOptions{
Path: filepath.Dir(realsrc),
Path: realsrc,
IsDir: isSrcDir,
})
if err != nil {
return errors.ErrEmptyKey
return fmt.Errorf("could not refresh index for source: %v", err)
}
refreshConfig := FileOptions{Path: realdst, IsDir: true}
if !isSrcDir {
@ -220,7 +220,7 @@ func MoveResource(source, realsrc, realdst string, isSrcDir bool) error {
}
err = index.RefreshFileInfo(refreshConfig)
if err != nil {
return errors.ErrEmptyKey
return fmt.Errorf("could not refresh index for dest: %v", err)
}
return nil
}

View File

@ -34,7 +34,7 @@ func (idx *Index) newScanner(origin string) {
}
// Log and sleep before indexing
logger.Debug(fmt.Sprintf("Next scan in %v\n", sleepTime))
logger.Debug(fmt.Sprintf("Next scan in %v", sleepTime))
time.Sleep(sleepTime)
idx.scannerMu.Lock()

View File

@ -1,6 +1,7 @@
package fileutils
import (
"fmt"
"io"
"os"
"path"
@ -11,15 +12,21 @@ import (
// By default, the rename system call is used. If src and dst point to different volumes,
// the file copy is used as a fallback.
func MoveFile(src, dst string) error {
fmt.Println("moving", src, dst)
if os.Rename(src, dst) == nil {
return nil
}
fmt.Println("copyfile instead", src, dst)
// fallback
err := CopyFile(src, dst)
if err != nil {
fmt.Println("ok it errored too", err)
_ = os.Remove(dst)
return err
}
fmt.Println("removing", src)
if err := os.Remove(src); err != nil {
return err
}

View File

@ -108,7 +108,6 @@ func withUserHelper(fn handleFunc) handleFunc {
}
tokenString, err := extractToken(r)
if err != nil {
logger.Debug(fmt.Sprintf("error extracting from request %v", err))
return http.StatusUnauthorized, err
}
data.token = tokenString

View File

@ -16,6 +16,7 @@ import (
"github.com/gtsteffaniak/filebrowser/backend/cache"
"github.com/gtsteffaniak/filebrowser/backend/errors"
"github.com/gtsteffaniak/filebrowser/backend/files"
"github.com/gtsteffaniak/filebrowser/backend/logger"
)
// resourceGetHandler retrieves information about a resource.
@ -60,7 +61,9 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
return renderJSON(w, r, fileInfo)
}
if algo := r.URL.Query().Get("checksum"); algo != "" {
checksums, err := files.GetChecksum(fileInfo.Path, algo)
idx := files.GetIndex(source)
realPath, _, _ := idx.GetRealPath(d.user.Scope, path)
checksums, err := files.GetChecksum(realPath, algo)
if err == errors.ErrInvalidOption {
return http.StatusBadRequest, nil
} else if err != nil {
@ -115,10 +118,7 @@ func resourceDeleteHandler(w http.ResponseWriter, r *http.Request, d *requestCon
}
// delete thumbnails
err = delThumbs(r.Context(), fileCache, fileInfo)
if err != nil {
return errToStatus(err), err
}
delThumbs(r.Context(), fileCache, fileInfo)
err = files.DeleteFiles(source, fileInfo.RealPath, filepath.Dir(fileInfo.RealPath))
if err != nil {
@ -184,10 +184,7 @@ func resourcePostHandler(w http.ResponseWriter, r *http.Request, d *requestConte
return http.StatusForbidden, nil
}
err = delThumbs(r.Context(), fileCache, fileInfo)
if err != nil {
return errToStatus(err), err
}
delThumbs(r.Context(), fileCache, fileInfo)
}
err = files.WriteFile(fileOpts, r.Body)
if err != nil {
@ -312,7 +309,9 @@ func resourcePatchHandler(w http.ResponseWriter, r *http.Request, d *requestCont
err = d.RunHook(func() error {
return patchAction(r.Context(), action, realSrc, realDest, d, fileCache, isSrcDir, source)
}, action, realSrc, realDest, d.user)
if err != nil {
logger.Debug(fmt.Sprintf("Could not run patch action. src=%v dst=%v err=%v", realSrc, realDest, err))
}
return errToStatus(err), err
}
@ -332,11 +331,11 @@ func addVersionSuffix(source string) string {
return source
}
func delThumbs(ctx context.Context, fileCache FileCache, file files.ExtendedFileInfo) error {
if err := fileCache.Delete(ctx, previewCacheKey(file.RealPath, "small", file.FileInfo.ModTime)); err != nil {
return err
func delThumbs(ctx context.Context, fileCache FileCache, file files.ExtendedFileInfo) {
err := fileCache.Delete(ctx, previewCacheKey(file.RealPath, "small", file.FileInfo.ModTime))
if err != nil {
logger.Debug(fmt.Sprintf("Could not delete small thumbnail: %v", err))
}
return nil
}
func patchAction(ctx context.Context, action, src, dst string, d *requestContext, fileCache FileCache, isSrcDir bool, index string) error {
@ -348,7 +347,6 @@ func patchAction(ctx context.Context, action, src, dst string, d *requestContext
err := files.CopyResource(index, src, dst, isSrcDir)
return err
case "rename", "move":
if !d.user.Perm.Rename {
return errors.ErrPermissionDenied
}
@ -366,10 +364,7 @@ func patchAction(ctx context.Context, action, src, dst string, d *requestContext
}
// delete thumbnails
err = delThumbs(ctx, fileCache, fileInfo)
if err != nil {
return err
}
delThumbs(ctx, fileCache, fileInfo)
return files.MoveResource(index, src, dst, isSrcDir)
default:
return fmt.Errorf("unsupported action %s: %w", action, errors.ErrInvalidRequestParams)

1
backend/img/testdata Symbolic link
View File

@ -0,0 +1 @@
../../frontend/tests/playwright-files/myfolder/testdata/

View File

@ -16,6 +16,7 @@ type Logger struct {
apiLevels []LogLevel
stdout bool
disabled bool
debugEnabled bool
disabledAPI bool
colors bool
}
@ -34,7 +35,7 @@ func NewLogger(filepath string, levels, apiLevels []LogLevel, noColors bool) (*L
}
fileWriter = file
}
flags := log.Ldate | log.Ltime
var flags int
if slices.Contains(levels, DEBUG) {
flags |= log.Lshortfile
}
@ -50,6 +51,7 @@ func NewLogger(filepath string, levels, apiLevels []LogLevel, noColors bool) (*L
levels: levels,
apiLevels: apiLevels,
disabled: slices.Contains(levels, DISABLED),
debugEnabled: slices.Contains(levels, DEBUG),
disabledAPI: slices.Contains(apiLevels, DISABLED),
colors: !noColors,
stdout: stdout,
@ -67,6 +69,9 @@ func SetupLogger(output, levels, apiLevels string, noColors bool) error {
if upperLevel == "WARNING" || upperLevel == "WARN" {
upperLevel = "WARN "
}
if upperLevel == "INFO" {
upperLevel = "INFO "
}
// Convert level strings to LogLevel
level, ok := stringToLevel[upperLevel]
if !ok {
@ -87,6 +92,9 @@ func SetupLogger(output, levels, apiLevels string, noColors bool) error {
if upperLevel == "WARNING" || upperLevel == "WARN" {
upperLevel = "WARN "
}
if upperLevel == "INFO" {
upperLevel = "INFO "
}
// Convert level strings to LogLevel
level, ok := stringToLevel[strings.ToUpper(upperLevel)]
if !ok {
@ -119,6 +127,7 @@ func SetupLogger(output, levels, apiLevels string, noColors bool) error {
loggers = append(loggers, logger)
return nil
}
func SplitByMultiple(str string) []string {
delimiters := []rune{'|', ',', ' '}
return strings.FieldsFunc(str, func(r rune) bool {

View File

@ -4,6 +4,7 @@ import (
"fmt"
"log"
"slices"
"time"
)
type LogLevel int
@ -38,7 +39,7 @@ type levelConsts struct {
}
var levels = levelConsts{
INFO: "INFO",
INFO: "INFO ", // with consistent space padding
FATAL: "FATAL",
ERROR: "ERROR",
WARNING: "WARN ", // with consistent space padding
@ -50,7 +51,7 @@ var levels = levelConsts{
// stringToLevel maps string representation to LogLevel
var stringToLevel = map[string]LogLevel{
"DEBUG": DEBUG,
"INFO": INFO,
"INFO ": INFO, // with consistent space padding
"ERROR": ERROR,
"DISABLED": DISABLED,
"WARN ": WARNING, // with consistent space padding
@ -71,15 +72,21 @@ func Log(level string, msg string, prefix, api bool, color string) {
continue
}
}
if logger.stdout && LEVEL == FATAL {
if logger.stdout && level == "FATAL" {
continue
}
writeOut := msg
if prefix {
writeOut = fmt.Sprintf("[%s] ", level) + writeOut
formattedTime := time.Now().Format("2006/01/02 15:04:05")
if logger.colors && color != "" {
formattedTime = formattedTime + color
}
if prefix || logger.debugEnabled {
logger.logger.SetPrefix(fmt.Sprintf("%s [%s] ", formattedTime, level))
} else {
logger.logger.SetPrefix(formattedTime + " ")
}
if logger.colors && color != "" {
writeOut = color + writeOut + "\033[0m"
writeOut = writeOut + "\033[0m"
}
err := logger.logger.Output(3, writeOut) // 3 skips this function for correct file:line
if err != nil {
@ -102,6 +109,8 @@ func Api(msg string, statusCode int) {
func Debug(msg string) {
if len(loggers) > 0 {
Log(levels.DEBUG, msg, true, false, GRAY)
} else {
log.Println("[DEBUG]: " + msg)
}
}

View File

@ -17,6 +17,9 @@ var Config Settings
func Initialize(configFile string) {
yamlData, err := loadConfigFile(configFile)
if err != nil && configFile != "config.yaml" {
logger.Fatal("Could not load specified config file: " + err.Error())
}
if err != nil {
logger.Warning(fmt.Sprintf("Could not load config file '%v', using default settings: %v", configFile, err))
}
@ -35,21 +38,22 @@ func Initialize(configFile string) {
logger.Fatal(fmt.Sprintf("Error getting source path: %v", err2))
}
source.Path = realPath
source.Name = "default" // Modify the local copy of the map value
Config.Server.Sources["default"] = source // Assign the modified value back to the map
source.Name = "default"
Config.Server.Sources = []Source{source} // temporary set only one source
}
} else {
realPath, err2 := filepath.Abs(Config.Server.Root)
if err2 != nil {
logger.Fatal(fmt.Sprintf("Error getting source path: %v", err2))
}
Config.Server.Sources = map[string]Source{
"default": {
Config.Server.Sources = []Source{
{
Name: "default",
Path: realPath,
},
}
}
baseurl := strings.Trim(Config.Server.BaseURL, "/")
if baseurl == "" {
Config.Server.BaseURL = "/"
@ -57,10 +61,6 @@ func Initialize(configFile string) {
Config.Server.BaseURL = "/" + baseurl + "/"
}
if !Config.Frontend.DisableDefaultLinks {
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
Text: "FileBrowser Quantum",
Url: "https://github.com/gtsteffaniak/filebrowser",
})
Config.Frontend.ExternalLinks = append(Config.Frontend.ExternalLinks, ExternalLink{
Text: fmt.Sprintf("(%v)", version.Version),
Title: version.CommitSHA,
@ -89,7 +89,6 @@ func Initialize(configFile string) {
log.Println("[ERROR] Failed to set up logger:", err)
}
}
}
func loadConfigFile(configFile string) ([]byte, error) {

View File

@ -53,7 +53,7 @@ type Server struct {
Root string `json:"root"`
UserHomeBasePath string `json:"userHomeBasePath"`
CreateUserDir bool `json:"createUserDir"`
Sources map[string]Source `json:"sources"`
Sources []Source `json:"sources"`
ExternalUrl string `json:"externalUrl"`
InternalUrl string `json:"internalUrl"` // used by integrations
}
@ -79,7 +79,7 @@ type LogConfig struct {
type Source struct {
Path string `json:"path"`
Name string
Name string `json:"name"`
Config IndexConfig `json:"config"`
}

View File

@ -12,7 +12,7 @@ async function globalSetup() {
await page.waitForURL("**/files/", { timeout: 100 });
let cookies = await context.cookies();
expect(cookies.find((c) => c.name == "auth")?.value).toBeDefined();
await expect(page).toHaveTitle('playwright-files - FileBrowser Quantum - Files');
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.context().storageState({ path: "./loginAuth.json" });
await browser.close();
}

View File

@ -158,10 +158,16 @@ export async function moveCopy(items, action = "copy", overwrite = false, rename
}
export async function checksum(url, algo) {
export async function checksum(path, algo) {
try {
const data = await resourceAction(`${url}?checksum=${algo}`, "GET");
return (await data.json()).checksums[algo];
const params = {
path: encodeURIComponent(removePrefix(path, "files")),
checksum: algo,
};
const apiPath = getApiPath("api/resources", params);
const res = await fetchURL(apiPath);
const data = await res.json();
return data.checksums[algo];
} catch (err) {
notify.showError(err.message || "Error fetching checksum");
throw err;

View File

@ -13,21 +13,20 @@
<div v-if="selectedCount > 0" class="button selected-count-header">
<span>{{ selectedCount }} selected</span>
</div>
<action
v-if="!headerButtons.select"
v-if="!isSearchActive"
icon="create_new_folder"
:label="$t('sidebar.newFolder')"
@action="showHover('newDir')"
/>
<action
v-if="!headerButtons.select"
v-if="!headerButtons.select && !isSearchActive"
icon="note_add"
:label="$t('sidebar.newFile')"
@action="showHover('newFile')"
/>
<action
v-if="!headerButtons.select"
v-if="!headerButtons.select && !isSearchActive"
icon="file_upload"
:label="$t('buttons.upload')"
@action="uploadFunc"
@ -40,7 +39,7 @@
show="info"
/>
<action
v-if="!isMultiple"
v-if="!isMultiple && !isSearchActive"
icon="check_circle"
:label="$t('buttons.selectMultiple')"
@action="toggleMultipleSelection"
@ -59,7 +58,7 @@
show="share"
/>
<action
v-if="headerButtons.rename"
v-if="headerButtons.rename && !isSearchActive"
icon="mode_edit"
:label="$t('buttons.rename')"
show="rename"
@ -102,6 +101,9 @@ export default {
};
},
computed: {
isSearchActive() {
return state.isSearchActive;
},
isMultiple() {
return state.multiple;
},

View File

@ -1,5 +1,9 @@
<template>
<div id="search" :class="{ active, ongoing, 'dark-mode': isDarkMode }">
<div
id="search"
:class="{ active, ongoing, 'dark-mode': isDarkMode }"
@click="clearContext"
>
<!-- Search input section -->
<div id="input" @click="open">
<!-- Close button visible when search is active -->
@ -115,11 +119,18 @@
</div>
<!-- List of search results -->
<ul v-show="results.length > 0">
<li v-for="(s, k) in results" :key="k" class="search-entry">
<a :href="getRelative(s.path)">
<li
v-for="(s, k) in results"
:key="k"
class="search-entry"
:class="{ active: activeStates[k] }"
:aria-label="baseName(s.path)"
>
<a :href="getRelative(s.path)" @contextmenu="addSelected(event, s)">
<Icon :mimetype="s.type" />
<span class="text-container">
{{ basePath(s.path, s.type === "directory")}}<b>{{ baseName(s.path) }}</b>
{{ basePath(s.path, s.type === "directory")
}}<b>{{ baseName(s.path) }}</b>
</span>
<div class="filesize">{{ humanSize(s.size) }}</div>
</a>
@ -136,6 +147,7 @@ import { search } from "@/api";
import { getters, mutations, state } from "@/store";
import { getHumanReadableFilesize } from "@/utils/filesizes";
import { removeTrailingSlash, removeLeadingSlash } from "@/utils/url";
import { removePrefix } from "@/utils/url.js";
import Icon from "@/components/Icon.vue";
@ -226,7 +238,7 @@ export default {
return this.isTypeSelectDisabled;
},
active() {
return getters.currentPromptName() == "search";
return state.isSearchActive;
},
isDarkMode() {
return getters.isDarkMode();
@ -255,17 +267,22 @@ export default {
return this.showHelp;
},
getContext() {
let path = state.route.path;
path = path.slice(1);
path = "./" + path.substring(path.indexOf("/") + 1);
path = path.replace(/\/+$/, "") + "/";
if (path == "./files/") {
path = "./";
}
return path;
return this.getRelativeContext();
},
activeStates() {
// Create a Set of combined `name` and `type` keys for efficient lookup
const selectedSet = new Set(
state.selected.map((item) => `${item.name}:${item.type}`)
);
const result = this.results.map((s) => selectedSet.has(`${s.name}:${s.type}`));
// Build a map of active states for the `results` array
return result;
},
},
methods: {
getRelativeContext() {
return removePrefix(decodeURIComponent(state.route.path), "files");
},
getRelative(path) {
return removeTrailingSlash(window.location.href) + "/" + removeLeadingSlash(path);
},
@ -304,15 +321,18 @@ export default {
return parts.pop();
},
open() {
if (!this.active) {
if (!state.isSearchActive) {
mutations.closeHovers();
mutations.closeSidebar();
mutations.resetSelected();
this.resetSearchFilters();
mutations.showHover("search");
mutations.setSearch(true);
}
},
close(event) {
this.value = "";
event.stopPropagation();
mutations.closeHovers();
mutations.setSearch(false);
},
keyup(event) {
if (event.keyCode === 27) {
@ -381,6 +401,24 @@ export default {
toggleHelp() {
this.showHelp = !this.showHelp;
},
clearContext() {
mutations.closeHovers();
},
addSelected(event, s) {
const pathParts = s.path.split("/");
const path = removePrefix(decodeURIComponent(state.route.path), "files") + s.path;
const modifiedItem = {
name: pathParts.pop(),
path: path,
size: s.size,
type: s.type,
source: "",
url: path,
fullPath: path,
};
mutations.resetSelected();
mutations.addSelected(modifiedItem);
},
},
};
</script>
@ -511,6 +549,10 @@ export default {
border-radius: 0.25em;
}
.search-entry.active {
background-color: var(--surfacePrimary);
}
.search-entry:hover {
background-color: var(--surfacePrimary);
}

View File

@ -52,7 +52,6 @@ import FileList from "./FileList.vue";
import { filesApi } from "@/api";
import buttons from "@/utils/buttons";
import * as upload from "@/utils/upload";
import { removePrefix } from "@/utils/url";
import { notify } from "@/notify";
export default {
@ -74,6 +73,14 @@ export default {
},
},
mounted() {
if (state.isSearchActive) {
this.items = [
{
from: "/files" + state.selected[0].url,
name: state.selected[0].name,
},
];
} else {
for (let item of state.selected) {
this.items.push({
from: state.req.items[item].url,
@ -81,6 +88,7 @@ export default {
name: state.req.items[item].name,
});
}
}
},
methods: {
copy: async function (event) {
@ -88,9 +96,8 @@ export default {
try {
// Define the action function
let action = async (overwrite, rename) => {
const loc = removePrefix(this.dest, "files");
for (let item of this.items) {
item.to = loc + "/" + item.name;
item.to = this.dest + item.name;
}
buttons.loading("copy");
await filesApi.moveCopy(this.items, "copy", overwrite, rename);
@ -123,12 +130,13 @@ export default {
await action(overwrite, rename);
}
mutations.closeHovers();
mutations.setSearch(false);
notify.showSuccess("Successfully copied file/folder, redirecting...");
setTimeout(() => {
this.$router.push(this.dest);
}, 1000);
} catch (error) {
notify.error(error);
notify.showError(error);
}
},
},

View File

@ -7,6 +7,9 @@
<p v-else>
{{ $t("prompts.deleteMessageMultiple", { count: selectedCount }) }}
</p>
<div style="display: grid" class="searchContext">
<span v-for="(item, index) in nav" :key="index"> {{ item }} </span>
</div>
</div>
<div class="card-action">
<button
@ -34,6 +37,7 @@ import { filesApi } from "@/api";
import buttons from "@/utils/buttons";
import { state, getters, mutations } from "@/store";
import { notify } from "@/notify";
import { removePrefix } from "@/utils/url";
export default {
name: "delete",
@ -47,6 +51,16 @@ export default {
currentPrompt() {
return getters.currentPrompt();
},
nav() {
if (state.isSearchActive) {
return [state.selected[0].path];
}
let paths = [];
for (let index of state.selected) {
paths.push(removePrefix(state.req.items[index].url, "files"));
}
return paths;
},
},
methods: {
closeHovers() {
@ -56,17 +70,24 @@ export default {
buttons.loading("delete");
try {
if (state.isSearchActive) {
await filesApi.remove(state.selected[0].url);
buttons.success("delete");
notify.showSuccess("Deleted item successfully");
mutations.closeHovers();
return;
}
if (!this.isListing) {
await filesApi.remove(state.route.path);
buttons.success("delete");
notify.showSuccess("Deleted item successfully");
this.currentPrompt?.confirm();
this.closeHovers();
mutations.closeHovers();
return;
}
this.closeHovers();
mutations.closeHovers();
if (getters.selectedCount() === 0) {
return;

View File

@ -99,6 +99,9 @@ export default {
return getters.isListing();
},
humanSize() {
if (state.isSearchActive) {
return getHumanReadableFilesize(state.selected[0].size);
}
if (getters.selectedCount() === 0 || !this.isListing) {
return getHumanReadableFilesize(state.req.size);
}
@ -112,6 +115,9 @@ export default {
return getHumanReadableFilesize(sum);
},
humanTime() {
if (state.isSearchActive) {
return "unknown";
}
if (getters.selectedCount() === 0) {
return formatTimestamp(state.req.modified, state.user.locale);
}
@ -121,19 +127,31 @@ export default {
);
},
modTime() {
if (state.isSearchActive) {
return "";
}
return new Date(Date.parse(state.req.modified)).toLocaleString();
},
name() {
if (state.isSearchActive) {
return state.selected[0].name;
}
return getters.selectedCount() === 0
? state.req.name
: state.req.items[this.selected[0]].name;
},
type() {
if (state.isSearchActive) {
return state.selected[0].type;
}
return getters.selectedCount() === 0
? state.req.type
: state.req.items[this.selected[0]].type;
},
dir() {
if (state.isSearchActive) {
return state.selected[0].type === "directory";
}
return (
getters.selectedCount() > 1 ||
(getters.selectedCount() === 0
@ -145,9 +163,12 @@ export default {
methods: {
async checksum(event, algo) {
event.preventDefault();
let link;
if (state.isSearchActive) {
const hash = await filesApi.checksum(state.selected[0].url, algo);
event.target.innerHTML = hash;
return;
}
if (getters.selectedCount()) {
link = state.req.items[this.selected[0]].url;
} else {

View File

@ -35,7 +35,6 @@
<button
class="button button--flat"
@click="move"
:disabled="$route.path === dest"
:aria-label="$t('buttons.move')"
:title="$t('buttons.move')"
>
@ -53,7 +52,6 @@ import { filesApi } from "@/api";
import buttons from "@/utils/buttons";
import * as upload from "@/utils/upload";
import { notify } from "@/notify";
import { removePrefix } from "@/utils/url";
export default {
name: "move",
@ -74,6 +72,14 @@ export default {
},
},
mounted() {
if (state.isSearchActive) {
this.items = [
{
from: "/files" + state.selected[0].url,
name: state.selected[0].name,
},
];
} else {
for (let item of state.selected) {
this.items.push({
from: state.req.items[item].url,
@ -81,6 +87,7 @@ export default {
name: state.req.items[item].name,
});
}
}
},
methods: {
move: async function (event) {
@ -88,18 +95,15 @@ export default {
try {
// Define the action function
let action = async (overwrite, rename) => {
const loc = removePrefix(this.dest, "files");
for (let item of this.items) {
item.to = loc + "/" + item.name;
item.to = this.dest + item.name;
}
buttons.loading("move");
await filesApi.moveCopy(this.items, "move", overwrite, rename);
};
// Fetch destination files
let dstResp = await filesApi.fetchFiles(this.dest);
let conflict = upload.checkConflict(this.items, dstResp.items);
let overwrite = false;
let rename = false;
@ -125,13 +129,13 @@ export default {
await action(overwrite, rename);
}
mutations.closeHovers();
mutations.setSearch(false);
notify.showSuccess("Successfully moved file/folder, redirecting...");
setTimeout(() => {
this.$router.push(this.dest);
}, 1000);
} catch (e) {
// Catch any errors from action or other parts of the flow
notify.showError(e);
} catch (error) {
notify.showError(error);
}
},
},

View File

@ -158,6 +158,9 @@ export default {
return getters.isListing(); // Access getter directly from the store
},
url() {
if (state.isSearchActive) {
return state.selected[0].url;
}
if (!this.isListing) {
return state.route.path;
}
@ -170,11 +173,15 @@ export default {
},
async beforeMount() {
try {
if (state.isSearchActive) {
this.subpath = state.selected[0].path;
} else {
let path = "." + getters.routePath("files");
if (getters.selectedCount() === 1) {
path = path + state.req.items[this.selected[0]].name;
}
this.subpath = decodeURIComponent(path);
}
// get last element of the path
const links = await shareApi.get(this.subpath);
this.links = links;
@ -238,6 +245,9 @@ export default {
return shareApi.getShareURL(share);
},
hasDownloadLink() {
if (state.isSearchActive) {
return state.selected[0].type != "directory";
}
return this.selected.length === 1 && !state.req.items[this.selected[0]].isDir;
},
buildDownloadLink(share) {

View File

@ -4,10 +4,10 @@
<div @click="navigateTo('/settings#profile-main')" class="inner-card">
<i class="material-icons">person</i>
{{ user.username }}
<i class="material-icons">settings</i>
<i aria-label="settings" class="material-icons">settings</i>
</div>
<div class="inner-card logout-button" @click="logout" >
<div class="inner-card logout-button" @click="logout">
<i v-if="canLogout" class="material-icons">exit_to_app</i>
</div>
</div>

View File

@ -1,5 +1,8 @@
<template>
<nav id="sidebar" :class="{ active: active, 'dark-mode': isDarkMode }">
<nav
id="sidebar"
:class="{ active: active, 'dark-mode': isDarkMode, 'behind-overlay': behindOverlay }"
>
<SidebarSettings v-if="isSettings"></SidebarSettings>
<SidebarGeneral v-else-if="isLoggedIn"></SidebarGeneral>
@ -8,14 +11,16 @@
<span v-for="item in externalLinks" :key="item.title">
<a :href="item.url" target="_blank" :title="item.title">{{ item.text }}</a>
</span>
<span v-if="name != ''"><h3>{{ name }}</h3></span>
<span v-if="name != ''">
<h4>{{ name }}</h4>
</span>
</div>
</nav>
</template>
<script>
import { externalLinks, name } from "@/utils/constants";
import { getters, mutations } from "@/store"; // Import your custom store
import { getters, mutations, state } from "@/store"; // Import your custom store
import SidebarGeneral from "./General.vue";
import SidebarSettings from "./Settings.vue";
@ -36,6 +41,7 @@ export default {
isLoggedIn: () => getters.isLoggedIn(),
isSettings: () => getters.isSettings(),
active: () => getters.isSidebarVisible(),
behindOverlay: () => state.isSearchActive,
},
methods: {
// Show the help overlay
@ -65,7 +71,11 @@ export default {
transition: 0.5s ease;
top: 4em;
padding-bottom: 4em;
background-color: #DDDDDD
background-color: #dddddd;
}
#sidebar.behind-overlay {
z-index: 3;
}
#sidebar.sticky {
@ -106,7 +116,7 @@ body.rtl .action {
text-align: right;
}
#sidebar .action>* {
#sidebar .action > * {
vertical-align: middle;
}
@ -121,7 +131,7 @@ body.rtl .action {
padding-bottom: 1em;
}
.credits>span {
.credits > span {
display: block;
margin-top: 0.5em;
margin-left: 0;

View File

@ -200,7 +200,7 @@ button:disabled {
}
#popup-notification.success {
background: var(--primaryColor);
background: var(--blue);
}
#popup-notification.error {
background: var(--red);

View File

@ -129,7 +129,6 @@ router.beforeResolve(async (to, from, next) => {
console.warn("Avoiding recursive navigation to the same route.");
return next(false);
}
// Set the page title using i18n
const title = i18n.global.t(titles[to.name as keyof typeof titles]);
document.title = name + " - " + title ;

View File

@ -120,8 +120,8 @@ export const getters = {
},
showOverlay: () => {
const hasPrompt = getters.currentPrompt() !== null && getters.currentPromptName() !== "more"
const shouldOverlaySidebar = getters.isSidebarVisible() && !getters.isStickySidebar()
return hasPrompt || shouldOverlaySidebar;
const showForSidebar = getters.isSidebarVisible() && !getters.isStickySidebar()
return hasPrompt || showForSidebar || state.isSearchActive;
},
showBreadCrumbs: () => {
return getters.currentView() == "listingView" ;

View File

@ -241,6 +241,10 @@ export const mutations = {
setSharePassword: (value) => {
state.sharePassword = value;
emitStateChanged();
}
},
setSearch: (value) => {
state.isSearchActive = value;
emitStateChanged();
},
};

View File

@ -5,6 +5,7 @@ export const state = reactive({
isSafari: /^((?!chrome|android).)*safari/i.test(navigator.userAgent),
activeSettingsView: "",
isMobile: window.innerWidth <= 800,
isSearchActive: false,
showSidebar: false,
usage: {
used: "0 B",

View File

@ -0,0 +1,19 @@
import { describe, it, expect } from 'vitest';
import { removePrefix } from './url.js';
describe('testurl', () => {
it('url prefix', () => {
let tests = [
{input: "test",trimArg: "",expected:"/test"},
{input: "/test", trimArg: "test",expected:"/"},
{input: "/my/test/file", trimArg: "",expected:"/my/test/file"},
{input: "/my/test/file", trimArg: "my",expected:"/test/file"},
{input: "/files/my/test/file", trimArg: "files",expected:"/my/test/file"},
]
for (let test of tests) {
expect(removePrefix(test.input, test.trimArg)).toEqual(test.expected);
}
});
});

View File

@ -124,10 +124,7 @@ export default {
res = await filesApi.fetchFiles(getters.routePath(), content);
}
data = res;
// Verify if the fetched path matches the current route
if (url.pathsMatch(res.path, `/${state.route.params.path}`)) {
document.title = `${res.name} - ${document.title}`;
}
document.title = `${document.title} - ${res.name}`;
} catch (e) {
notify.showError(e);
this.error = e;

View File

@ -19,8 +19,13 @@
></editorBar>
<defaultBar v-else :class="{ 'dark-mode-header': isDarkMode }"></defaultBar>
<sidebar></sidebar>
<search v-if="showSearch"></search>
<main :class="{ 'dark-mode': isDarkMode, moveWithSidebar: moveWithSidebar, 'main-padding': showPadding }">
<main
:class="{
'dark-mode': isDarkMode,
moveWithSidebar: moveWithSidebar,
'main-padding': showPadding,
}"
>
<router-view></router-view>
</main>
<prompts :class="{ 'dark-mode': isDarkMode }"></prompts>
@ -38,7 +43,6 @@ import defaultBar from "./bars/Default.vue";
import listingBar from "./bars/ListingBar.vue";
import Prompts from "@/components/prompts/Prompts.vue";
import Sidebar from "@/components/sidebar/Sidebar.vue";
import Search from "@/components/Search.vue";
import ContextMenu from "@/components/ContextMenu.vue";
import { notify } from "@/notify";
@ -49,7 +53,6 @@ export default {
name: "layout",
components: {
ContextMenu,
Search,
defaultBar,
editorBar,
listingBar,
@ -74,9 +77,6 @@ export default {
showPadding() {
return getters.showBreadCrumbs();
},
showSearch() {
return getters.isLoggedIn() && this.currentView == "listingView";
},
isLoggedIn() {
return getters.isLoggedIn();
},
@ -135,6 +135,7 @@ export default {
resetPrompts() {
mutations.closeSidebar();
mutations.closeHovers();
mutations.setSearch(false);
},
},
};

View File

@ -72,6 +72,8 @@ export default {
},
},
mounted() {
mutations.closeHovers();
mutations.setSearch(false);
mutations.setActiveSettingsView(getters.currentHash());
},
methods: {

View File

@ -280,7 +280,7 @@ export default {
file.hash = this.hash;
this.token = file.token;
mutations.replaceRequest(file);
document.title = `${file.name} - ${document.title}`;
document.title = `${document.title} - ${file.name}`;
} catch (error) {
this.error = error;
}

View File

@ -31,18 +31,17 @@ export default {
},
methods: {
close() {
mutations.closeHovers();
if (getters.isSettings()) {
// Use this.isSettings to access the computed property
router.push({ path: "/files/", hash: "" });
mutations.closeHovers();
return;
}
mutations.closeHovers();
setTimeout(() => {
mutations.replaceRequest({});
let uri = url.removeLastDir(state.route.path) + "/";
router.push({ path: uri });
}, 50);
},
},
};

View File

@ -5,14 +5,15 @@
icon="menu"
:label="$t('buttons.toggleSidebar')"
@action="toggleSidebar()"
:disabled="showOverlay"
:disabled="isSearchActive"
/>
<search v-if="showSearch"></search>
<action
class="menu-button"
icon="grid_view"
:label="$t('buttons.switchView')"
@action="switchView"
:disabled="showOverlay"
:disabled="isSearchActive"
/>
</header>
</template>
@ -27,11 +28,13 @@
<script>
import { state, mutations, getters } from "@/store";
import Action from "@/components/Action.vue";
import Search from "@/components/Search.vue";
export default {
name: "listingView",
components: {
Action,
Search,
},
data: function () {
return {
@ -40,8 +43,11 @@ export default {
};
},
computed: {
showOverlay() {
return getters.currentPrompt() !== null && getters.currentPromptName() !== "more";
showSearch() {
return getters.isLoggedIn() && getters.currentView() == "listingView";
},
isSearchActive() {
return state.isSearchActive;
},
viewIcon() {
const icons = {
@ -78,7 +84,7 @@ export default {
const currentIndex = this.viewModes.indexOf(state.user.viewMode);
const nextIndex = (currentIndex + 1) % this.viewModes.length;
const newView = this.viewModes[nextIndex];
mutations.updateCurrentUser({ "viewMode": newView });
mutations.updateCurrentUser({ viewMode: newView });
},
},
};

View File

@ -280,6 +280,7 @@ export default {
},
},
mounted() {
mutations.setSearch(false);
this.lastSelected = state.selected;
// Check the columns size for the first time.
this.colunmsResize();
@ -504,6 +505,9 @@ export default {
}
},
keyEvent(event) {
if (state.isSearchActive) {
return;
}
const { key, ctrlKey, metaKey, which } = event;
// Check if the key is alphanumeric
const isAlphanumeric = /^[a-z0-9]$/i.test(key);
@ -518,10 +522,11 @@ export default {
// Handle the space bar key
if (key === " ") {
event.preventDefault();
if (getters.currentPromptName() == "search") {
if (state.isSearchActive) {
mutations.setSearch(false);
mutations.closeHovers();
} else {
mutations.showHover("search");
mutations.setSearch(true);
}
}
if (getters.currentPromptName() != null) {

View File

@ -14,12 +14,12 @@ test("logout", async ({ page, context }) => {
await page.goto('/');
await expect(page.locator("div.wrong")).toBeHidden();
await page.waitForURL("**/files/", { timeout: 100 });
await expect(page).toHaveTitle('playwright-files - FileBrowser Quantum - Files');
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
let cookies = await context.cookies();
expect(cookies.find((c) => c.name == "auth")?.value).toBeDefined();
await page.locator('div.inner-card.logout-button').click();
await page.waitForURL("**/login", { timeout: 100 });
await expect(page).toHaveTitle('FileBrowser Quantum - Login');
await expect(page).toHaveTitle("Graham's Filebrowser - Login");
cookies = await context.cookies();
expect(cookies.find((c) => c.name == "auth")?.value).toBeUndefined();
});

View File

@ -0,0 +1,57 @@
import { test, expect } from "@playwright/test";
test("info from listing", async ({ page, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('a[aria-label="file.tar.gz"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="file.tar.gz"]').click( { button: "right" });
await page.locator('.selected-count-header').waitFor({ state: 'visible' });
await expect(page.locator('.selected-count-header')).toHaveText('1 selected');
await page.locator('button[aria-label="Info"]').click();
await expect(page.locator('.break-word')).toHaveText('Display Name: file.tar.gz');
});
test("info from search", async ({ page, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('#search').click()
await page.locator('#main-input').fill('file.tar.gz');
await expect(page.locator('#result-list')).toHaveCount(1);
await page.locator('li[aria-label="file.tar.gz"]').click({ button: "right" });
await page.locator('.selected-count-header').waitFor({ state: 'visible' });
await expect(page.locator('.selected-count-header')).toHaveText('1 selected');
await page.locator('button[aria-label="Info"]').click();
await expect(page.locator('.break-word')).toHaveText('Display Name: file.tar.gz');
})
test("copy from listing", async ({ page, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('a[aria-label="copyme.txt"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="copyme.txt"]').click( { button: "right" });
await page.locator('.selected-count-header').waitFor({ state: 'visible' });
await expect(page.locator('.selected-count-header')).toHaveText('1 selected');
await page.locator('button[aria-label="Copy file"]').click();
//await expect(page.locator('.searchContext')).toHaveText('Path: /');
await page.locator('li[aria-label="myfolder"]').click();
await page.locator('button[aria-label="Copy"]').click();
const popup = page.locator('#popup-notification-content');
await popup.waitFor({ state: 'visible' });
await expect(popup).toHaveText("Successfully copied file/folder, redirecting...");
await page.waitForURL('**/myfolder/');
await expect(page).toHaveTitle("Graham's Filebrowser - Files - myfolder");
// verify exists and copy again
await page.locator('a[aria-label="copyme.txt"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="copyme.txt"]').click( { button: "right" });
await page.locator('.selected-count-header').waitFor({ state: 'visible' });
await expect(page.locator('.selected-count-header')).toHaveText('1 selected');
await page.locator('button[aria-label="Copy file"]').click();
//await expect(page.locator('.searchContext')).toHaveText('Path: /');
await page.locator('li[aria-label=".."]').click();
await page.locator('button[aria-label="Copy"]').click();
const popup2 = page.locator('#popup-notification-content');
await popup2.waitFor({ state: 'visible' });
//await expect(popup2).toHaveText("Successfully copied file/folder, redirecting...");
//await page.waitForURL('**/testdata/');
//await expect(page).toHaveTitle("Graham's Filebrowser - Files - testdata");
})

View File

@ -0,0 +1 @@
test file for playwright

View File

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -1,11 +1,55 @@
import { test, expect } from "@playwright/test";
test("file preview", async ({ page, context }) => {
test("blob file preview", async ({ page, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle('playwright-files - FileBrowser Quantum - Files');
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('a[aria-label="file.tar.gz"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="file.tar.gz"]').dblclick();
await expect(page).toHaveTitle('file.tar.gz - FileBrowser Quantum - Files');
await expect(page).toHaveTitle("Graham's Filebrowser - Files - file.tar.gz");
await page.locator('button[title="Close"]').click();
await expect(page).toHaveTitle('playwright-files - FileBrowser Quantum - Files');
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
});
test("text file editor", async ({ page, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('a[aria-label="copyme.txt"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="copyme.txt"]').dblclick();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - copyme.txt");
const firstLineText = await page.locator('.ace_text-layer .ace_line').first().textContent();
expect(firstLineText).toBe('test file for playwright');
await page.locator('button[title="Close"]').click();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
});
test("navigate folders", async ({ page, context }) => {
await page.goto("/files/");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('a[aria-label="myfolder"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="myfolder"]').dblclick();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - myfolder");
await page.locator('a[aria-label="testdata"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="testdata"]').dblclick();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - testdata");
await page.locator('a[aria-label="gray-sample.jpg"]').waitFor({ state: 'visible' });
await page.locator('a[aria-label="gray-sample.jpg"]').dblclick();
await expect(page).toHaveTitle("Graham's Filebrowser - Files - gray-sample.jpg");
});
test("navigating images", async ({ page, context }) => {
await page.goto("/files/myfolder/testdata/20130612_142406.jpg");
await expect(page).toHaveTitle("Graham's Filebrowser - Files - 20130612_142406.jpg");
await page.locator('button[aria-label="Previous"]').waitFor({ state: 'hidden' });
await page.mouse.move(100, 100);
await page.locator('button[aria-label="Next"]').waitFor({ state: 'visible' });
await page.locator('button[aria-label="Next"]').click();
// went to next image
await expect(page).toHaveTitle("Graham's Filebrowser - Files - gray-sample.jpg");
await page.locator('button[aria-label="Previous"]').waitFor({ state: 'hidden' });
await page.locator('button[aria-label="Next"]').waitFor({ state: 'hidden' });
await page.mouse.move(100, 100);
await page.locator('button[aria-label="Next"]').waitFor({ state: 'visible' });
await page.locator('button[aria-label="Next"]').click();
// went to next image
await expect(page).toHaveTitle("Graham's Filebrowser - Files - IMG_2578.JPG");
});

View File

@ -0,0 +1,41 @@
import { test, expect } from "@playwright/test";
test("sidebar links", async ({ page }) => {
await page.goto("/files/");
// Verify the page title
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
// Locate the credits container
const credits = page.locator('.credits'); // Fix the selector to match the HTML structure
// Assert that the <h3> contains the text 'FileBrowser Quantum'
await expect(credits.locator("h4")).toHaveText("Graham's Filebrowser");
// Assert that the <a> contains the text 'A playwright test'
await expect(credits.locator("span").locator("a")).toHaveText('A playwright test');
// Assert that the <a> does not contain the text 'Help'
await expect(credits.locator("span").locator("a")).not.toHaveText('Help');
});
test("adjusting theme colors", async ({ page }) => {
await page.goto("/files/");
const originalPrimaryColor = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue('--primaryColor').trim();
});
await expect(originalPrimaryColor).toBe('#2196f3');
// Verify the page title
await expect(page).toHaveTitle("Graham's Filebrowser - Files - playwright-files");
await page.locator('i[aria-label="settings"]').click();
await expect(page).toHaveTitle("Graham's Filebrowser - Settings");
await page.locator('button', { hasText: 'violet' }).click();
const popup = page.locator('#popup-notification-content');
await popup.waitFor({ state: 'visible' });
await expect(popup).toHaveText('Settings updated!');
const newPrimaryColor = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue('--primaryColor').trim();
});
await expect(newPrimaryColor).toBe('#9b59b6');
});