<?php
// directory_tree_file_view.php
// /home/www/ 파일 시스템 트리
// 디렉토리 토글 + 파일 코드보기 / 새창열기
// daemon_*, cron_* 파일은 새창열기 비활성
// + 즉시 검색 + 전체 디렉토리 열기
date_default_timezone_set('Asia/Seoul');
header('Content-Type: text/html; charset=UTF-8');
// --------------------
// 공통 함수
// --------------------
function h($s){
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
// --------------------
// 설정
// --------------------
$ROOT_PATH = '/home/www';
$ROOT_PATH = rtrim(realpath($ROOT_PATH), '/');
if ($ROOT_PATH === false) {
die('루트 경로 오류');
}
// --------------------
// ASCII 트리 출력 (?tree=)
// --------------------
if (isset($_GET['tree'])) {
$targetPath = $_GET['tree'];
if ($targetPath === '' || $targetPath === null) {
$realTarget = $ROOT_PATH;
} else {
$realTarget = realpath($targetPath);
if (!$realTarget || strpos($realTarget, $ROOT_PATH) !== 0) {
http_response_code(403);
exit('접근 불가');
}
}
if (!is_dir($realTarget)) {
http_response_code(403);
exit('디렉토리가 아님');
}
function generateAsciiTree($path, $rootLimit, $prefix = '', $isLast = false) {
$result = '';
$name = basename($path);
if ($name === '') {
$name = $path;
}
$result .= $prefix . ($isLast ? '└─ ' : '├─ ') . $name . "\n";
$items = [];
$dh = @opendir($path);
if ($dh) {
while (($item = readdir($dh)) !== false) {
if ($item === '.' || $item === '..') continue;
$itemPath = $path . '/' . $item;
$realItem = realpath($itemPath);
if (!$realItem || strpos($realItem, $rootLimit) !== 0) continue;
if (is_link($realItem)) continue;
$items[] = ['name' => $item, 'path' => $realItem, 'isDir' => is_dir($realItem)];
}
closedir($dh);
}
usort($items, function($a, $b) {
if ($a['isDir'] === $b['isDir']) {
return strcmp($a['name'], $b['name']);
}
return $a['isDir'] ? -1 : 1;
});
$count = count($items);
foreach ($items as $index => $item) {
$isLastItem = ($index === $count - 1);
$newPrefix = $prefix . ($isLast ? ' ' : '│ ');
if ($item['isDir']) {
$result .= generateAsciiTree($item['path'], $rootLimit, $newPrefix, $isLastItem);
} else {
$result .= $newPrefix . ($isLastItem ? '└─ ' : '├─ ') . $item['name'] . "\n";
}
}
return $result;
}
$treeText = $realTarget . "\n";
$treeText .= generateAsciiTree($realTarget, $ROOT_PATH, '', true);
header('Content-Type: text/plain; charset=UTF-8');
echo $treeText;
exit;
}
// --------------------
// 파일 코드보기 / tail 보기 (?view=, ?mode=tail)
// --------------------
if (isset($_GET['view'])) {
$path = $_GET['view'];
$realFile = realpath($ROOT_PATH . '/' . ltrim($path, '/'));
$mode = $_GET['mode'] ?? 'full';
if (!$realFile || strpos($realFile, $ROOT_PATH) !== 0) {
http_response_code(403);
exit('접근 불가');
}
$allow = ['txt','php','log','md','json','css','js','html'];
$ext = strtolower(pathinfo($realFile, PATHINFO_EXTENSION));
if (!in_array($ext, $allow)) {
http_response_code(403);
exit('허용되지 않은 파일');
}
$content = @file_get_contents($realFile);
if ($content === false) {
http_response_code(500);
exit('파일 읽기 실패');
}
if ($mode === 'tail' && $ext === 'log') {
$lines = explode("\n", $content);
$n = min(200, count($lines));
$content = implode("\n", array_slice($lines, -$n));
}
?>
<title><?=h($path)?></title>
<div class="header"><?=h($path)?><?php if($mode==='tail'):?> (마지막 200줄)<?php endif;?></div>
<pre><?=h($content)?></pre>
<?php
exit;
}
// --------------------
// 디렉토리 스캔 (파일 개수 계산 포함)
// --------------------
function countFiles($items) {
$count = 0;
foreach ($items as $it) {
if ($it['type'] === 'file') {
$count++;
} else {
$count += countFiles($it['children']);
}
}
return $count;
}
function scanDirTree(string $path, string $root, int $depth = 0): array {
// 재귀 깊이 5로 제한
if ($depth > 5) {
return [];
}
$items = [];
$dh = @opendir($path);
if (!$dh) return $items;
while (($name = readdir($dh)) !== false) {
// 디렉토리 내 항목 1000개 초과 시 스캔 중단
if (count($items) >= 1000) {
break;
}
if ($name === '.' || $name === '..') continue;
$real = realpath($path.'/'.$name);
if (!$real || strpos($real, $root) !== 0) continue;
if (is_dir($real)) {
$children = scanDirTree($real, $root, $depth + 1);
$items[] = [
'type'=>'dir',
'name'=>$name,
'path'=>$real,
'children'=>$children,
'fileCount'=>countFiles($children),
'depth'=>$depth
];
} else {
$items[] = [
'type'=>'file',
'name'=>$name,
'path'=>$real,
'size'=>@filesize($real),
'mtime'=>@filemtime($real),
'depth'=>$depth
];
}
}
closedir($dh);
usort($items,function($a,$b){
if ($a['type']===$b['type']) return strcmp($a['name'],$b['name']);
return $a['type']==='dir'?-1:1;
});
return $items;
}
$scanStartTime = microtime(true);
$tree = scanDirTree($ROOT_PATH,$ROOT_PATH);
$scanEndTime = microtime(true);
$scanTime = date('Y-m-d H:i:s');
// 변경 감지를 위한 경로 목록 생성
function collectPaths($items) {
$paths = [];
foreach ($items as $it) {
$paths[] = $it['path'];
if ($it['type'] === 'dir' && !empty($it['children'])) {
$paths = array_merge($paths, collectPaths($it['children']));
}
}
return $paths;
}
$currentPaths = collectPaths($tree);
require_once '/home/www/GNU/_PAGE/head.php';
?>
<title>FILE TREE</title>
<style>
:root {
--bg-primary: #0a0e27;
--bg-secondary: #151932;
--bg-tertiary: #1e2742;
--bg-card: #1a1f3a;
--bg-hover: #252b4a;
--bg-border: #2a3458;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent-primary: #3b82f6;
--accent-secondary: #8b5cf6;
--success: #10b981;
--danger: #ef4444;
--warning: #f59e0b;
--border-color: #2a3458;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Malgun Gothic', 'Roboto', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
.directory_tree { padding: 50px; }
h2 {
color: #f59e0b !important;
font-size: 24px;
font-weight: 700;
margin-bottom: 20px;
color: var(--text-primary);
}
h2 i { color: #21b3d8 !important; padding-left: 10px; }
h3 {
font-size: 18px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text-primary);
}
.scan-info {
color: var(--text-muted);
font-size: 13px;
margin-bottom: 16px;
padding: 8px 12px;
background: var(--bg-tertiary);
border-radius: 6px;
border: 1px solid var(--border-color);
display: inline-block;
}
.search-box {
margin-bottom: 24px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
#searchInput {
flex: 1;
min-width: 300px;
padding: 10px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
transition: all 0.2s ease;
}
#searchInput:focus {
outline: none;
border-color: var(--accent-primary);
background: var(--bg-card);
}
#searchInput::placeholder {
color: var(--text-muted);
}
.btn {
padding: 8px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-primary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-block;
}
.btn:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
color: var(--accent-primary);
}
.status-info {
color: var(--text-secondary);
font-size: 13px;
padding: 8px 12px;
background: var(--bg-tertiary);
border-radius: 6px;
border: 1px solid var(--border-color);
}
.search-result {
color: var(--accent-primary);
font-size: 13px;
font-weight: 600;
padding: 8px 12px;
}
.pinned-section {
margin-bottom: 24px;
padding: 16px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
}
.pinned-items {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.pinned-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 13px;
transition: all 0.2s ease;
}
.pinned-item:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.pinned-item-remove {
cursor: pointer;
color: var(--text-muted);
font-weight: bold;
padding: 0 4px;
}
.pinned-item-remove:hover {
color: var(--danger);
}
.node {
margin-left: 0;
}
.dir-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin: 2px 0;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.dir-head:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.dir-head.pinned {
border-color: var(--warning);
background: rgba(245, 158, 11, 0.1);
}
.dir-icon {
font-size: 16px;
flex-shrink: 0;
}
.dir-toggle {
font-size: 12px;
color: var(--text-secondary);
width: 16px;
text-align: center;
flex-shrink: 0;
}
.dir-name {
flex: 1;
font-weight: 500;
color: var(--text-primary);
}
.dir-count {
color: var(--text-muted);
font-size: 12px;
padding: 2px 8px;
background: var(--bg-tertiary);
border-radius: 4px;
}
.dir-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.dir-pin {
cursor: pointer;
font-size: 14px;
color: var(--text-muted);
transition: color 0.2s;
flex-shrink: 0;
}
.dir-pin:hover {
color: var(--warning);
}
.dir-pin.active {
color: var(--warning);
}
.dir-children {
display: none;
margin-left: 24px;
border-left: 1px solid var(--border-color);
padding-left: 12px;
}
.dir-children.open {
display: block;
}
.file {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin: 2px 0;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
transition: all 0.2s ease;
position: relative;
}
.file:hover {
background: var(--bg-hover);
border-color: var(--accent-primary);
}
.file.hidden {
display: none;
}
.file.pinned {
border-color: var(--warning);
background: rgba(245, 158, 11, 0.1);
}
.file.recent-clicked {
border-color: var(--accent-primary);
background: rgba(59, 130, 246, 0.1);
}
.file-recent-strong {
background: rgba(16, 185, 129, 0.15) !important;
border-color: var(--success) !important;
}
.file-recent-weak {
background: rgba(16, 185, 129, 0.08) !important;
}
.file-error {
border-left: 3px solid var(--danger) !important;
}
.file-warn {
border-left: 3px solid var(--warning) !important;
}
.file-fail {
border-left: 3px solid var(--danger) !important;
}
.file.new-item {
animation: pulse-new 2s ease-out;
}
@keyframes pulse-new {
0% { background-color: rgba(59, 130, 246, 0.3); }
100% { background-color: transparent; }
}
.file-icon {
font-size: 16px;
flex-shrink: 0;
}
.file-name {
flex: 1;
color: var(--text-primary);
font-weight: 400;
}
.file-name-highlight {
background: var(--warning);
color: var(--bg-primary);
padding: 2px 4px;
border-radius: 3px;
font-weight: 600;
}
.file-badge {
display: inline-block;
padding: 2px 6px;
margin-left: 8px;
font-size: 11px;
font-weight: 600;
border-radius: 4px;
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.file-badge.php { background: rgba(119, 123, 192, 0.2); color: #777bc0; }
.file-badge.js { background: rgba(247, 223, 30, 0.2); color: #f7df1e; }
.file-badge.css { background: rgba(86, 61, 124, 0.2); color: #563d7c; }
.file-badge.json { background: rgba(255, 255, 255, 0.2); color: #fff; }
.file-badge.log { background: rgba(158, 158, 158, 0.2); color: #9e9e9e; }
.file-badge.md { background: rgba(0, 0, 0, 0.2); color: #fff; }
.file-badge.txt { background: rgba(158, 158, 158, 0.2); color: #9e9e9e; }
.file-badge.html { background: rgba(227, 79, 38, 0.2); color: #e34f26; }
.file-badge.daemon { background: rgba(239, 68, 68, 0.2); color: #ef4444; }
.file-badge.cron { background: rgba(245, 158, 11, 0.2); color: #f59e0b; }
.file-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.file-pin {
cursor: pointer;
font-size: 14px;
color: var(--text-muted);
transition: color 0.2s;
flex-shrink: 0;
}
.file-pin:hover {
color: var(--warning);
}
.file-pin.active {
color: var(--warning);
}
.file-size {
color: var(--text-muted);
font-size: 12px;
flex-shrink: 0;
}
.file-tooltip {
position: fixed;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 8px 12px;
font-size: 12px;
color: var(--text-primary);
white-space: pre-line;
z-index: 10000;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.file-tooltip.show {
opacity: 1;
}
.tree-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 10000;
align-items: center;
justify-content: center;
}
.tree-modal.show {
display: flex;
}
.tree-modal-content {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
width: 90%;
max-width: 800px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.8);
}
.tree-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border-color);
}
.tree-modal-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.tree-modal-close {
background: none;
border: none;
color: var(--text-secondary);
font-size: 24px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s;
}
.tree-modal-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.tree-modal-body {
padding: 20px;
overflow: auto;
flex: 1;
}
.tree-modal-body pre {
margin: 0;
padding: 0;
background: transparent;
color: var(--text-primary);
font-family: monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre;
overflow-x: auto;
}
.tree-modal-body::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.tree-modal-body::-webkit-scrollbar-track {
background: var(--bg-secondary);
border-radius: 10px;
}
.tree-modal-body::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 10px;
border: 2px solid var(--bg-secondary);
}
.tree-modal-body::-webkit-scrollbar-thumb:hover {
background: var(--bg-hover);
}
@media (max-width: 768px) {
body {
padding: 12px;
}
.search-box {
flex-direction: column;
align-items: stretch;
}
#searchInput {
min-width: 100%;
}
.dir-children {
margin-left: 16px;
}
.dir-actions,
.file-actions {
flex-direction: column;
width: 100%;
}
.btn {
width: 100%;
text-align: center;
}
}
/*---------| BODY SCROLLBAR |---------*/
body::-webkit-scrollbar { width:10px; }
body::-webkit-scrollbar-thumb { background-color:#333; border:1px solid #222; }
body::-webkit-scrollbar-thumb:hover { background-color:#555; cursor:default; }
body::-webkit-scrollbar-track { background-color:#0b0e11; }
</style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<div class="directory_tree">
<h2><i class="fa-solid fa-folder"></i> 파일 트리 (/home/www)</h2>
<div style="margin-bottom:12px;">
<button class="btn" onclick="showTreeModal('')" style="font-size:13px;padding:6px 12px;">디렉토리 구도 보기</button>
</div>
<div class="scan-info">스캔 기준: <?=h($scanTime)?></div>
<div id="pinnedSection" class="pinned-section" style="display:none;">
<h3>⭐ 고정된 항목</h3>
<div id="pinnedItems" class="pinned-items"></div>
</div>
<div class="search-box">
<input id="searchInput" placeholder="파일명 검색 (즉시) - /: 포커스, ESC: 초기화, *: 전체열기, R: 상태초기화">
<button id="toggleAllBtn" class="status-info">전체 디렉토리 열기</button>
<button id="closeAllBtn" class="status-info">모든 디렉토리 닫기</button>
<button id="resetStateBtn" class="status-info">상태 초기화</button>
<span id="openCount" class="status-info">열림: 0</span>
<span id="searchResult" class="search-result"></span>
</div>
<div class="node">
<div class="dir-head" onclick="toggleDir(this)" data-path="/home/www" data-rel-path="" data-depth="0">
<span class="dir-icon">📁</span>
<div class="dir-toggle">▶</div>
<div class="dir-name">/home/www</div>
<div class="dir-actions">
<span class="btn" onclick="event.stopPropagation();showTreeModal('')">디렉토리 구도</span>
<span class="btn" onclick="event.stopPropagation();copyDirPath('/home/www')">경로복사</span>
<span class="btn" onclick="event.stopPropagation();collapseDir('/home/www')">이 디렉토리 접기</span>
</div>
<span class="dir-pin" onclick="event.stopPropagation();togglePin(this, '/home/www', 'dir')">⭐</span>
</div>
<div class="dir-children">
<?php
function renderTree($items,$root){
foreach($items as $it){
if($it['type']==='dir'){
$rel = ltrim(str_replace($root,'',$it['path']),'/');
echo '<div class="node">';
echo '<div class="dir-head" onclick="toggleDir(this)" data-path="'.h($it['path']).'" data-rel-path="'.h($rel).'" data-depth="'.h($it['depth'] ?? 0).'"';
echo '>';
echo '<span class="dir-icon">📁</span>';
echo '<div class="dir-toggle">▶</div>';
echo '<div class="dir-name">'.h($it['name']).'</div>';
if ($it['fileCount'] > 0) {
echo '<span class="dir-count">('.$it['fileCount'].')</span>';
}
echo '<div class="dir-actions">';
echo '<span class="btn" onclick="event.stopPropagation();showTreeModalByPath(\''.h($it['path']).'\')">디렉토리 구도</span>';
echo '<span class="btn" onclick="event.stopPropagation();copyDirPath(\''.h($it['path']).'\')">경로복사</span>';
echo '<span class="btn" onclick="event.stopPropagation();collapseDir(\''.h($it['path']).'\')">이 디렉토리 접기</span>';
echo '</div>';
echo '<span class="dir-pin" onclick="event.stopPropagation();togglePin(this, \''.h($it['path']).'\', \'dir\')">⭐</span>';
echo '</div><div class="dir-children">';
renderTree($it['children'],$root);
echo '</div></div>';
} else {
$rel = ltrim(str_replace($root,'',$it['path']),'/');
$fname = $it['name'];
$ext = strtolower(pathinfo($fname, PATHINFO_EXTENSION));
// str_starts_with() 대신 substr() 사용 (PHP 7.x 호환)
$isDaemon = (strlen($fname) >= 7 && substr($fname, 0, 7) === 'daemon_');
$isCron = (strlen($fname) >= 5 && substr($fname, 0, 5) === 'cron_');
$isDaemonCron = $isDaemon || $isCron;
$mtime = $it['mtime'] ?? 0;
$now = time();
$diff = $now - $mtime;
$recentClass = '';
if ($diff < 600) {
$recentClass = 'file-recent-strong';
} elseif ($diff < 3600) {
$recentClass = 'file-recent-weak';
}
$errorClass = '';
$lowerName = strtolower($fname);
if (strpos($lowerName, 'error') !== false) {
$errorClass = 'file-error';
} elseif (strpos($lowerName, 'warn') !== false) {
$errorClass = 'file-warn';
} elseif (strpos($lowerName, 'fail') !== false) {
$errorClass = 'file-fail';
}
$tooltipText = "경로: /$rel\n";
$tooltipText .= "확장자: " . ($ext ? '.' . $ext : '없음') . "\n";
$tooltipText .= "크기: " . number_format($it['size']) . " bytes\n";
if ($mtime > 0) {
$tooltipText .= "수정: " . date('Y-m-d H:i:s', $mtime);
}
echo '<div class="file" data-name="'.h(strtolower($fname)).'" data-tooltip="'.h($tooltipText).'" data-path="'.h($rel).'" data-full-path="'.h($it['path']).'" data-depth="'.h($it['depth'] ?? 0).'"';
$classes = [];
if ($recentClass) $classes[] = $recentClass;
if ($errorClass) $classes[] = $errorClass;
if (!empty($classes)) {
echo ' class="'.implode(' ', $classes).'"';
}
echo '>';
echo '<span class="file-icon">📄</span>';
echo '<div class="file-name">'.h($fname);
if ($ext && in_array($ext, ['php','js','css','json','log','md','txt','html'])) {
echo '<span class="file-badge '.h($ext).'">.'.h($ext).'</span>';
}
if ($isDaemonCron) {
echo '<span class="file-badge '.h($isDaemon?'daemon':'cron').'">'.h($isDaemon?'DAEMON':'CRON').'</span>';
}
echo '</div>';
echo '<div class="file-actions">';
if (!$isDaemonCron && !empty($rel)) {
echo '<a class="btn" target="_blank" href="/'.h($rel).'">새창열기</a>';
}
echo '<a class="btn" target="_blank" href="?view='.urlencode($rel).'">코드보기</a>';
if ($ext === 'log') {
echo '<a class="btn" target="_blank" href="?view='.urlencode($rel).'&mode=tail">tail 보기</a>';
}
echo '<span class="btn" onclick="copyPath(\''.h($rel).'\')">경로복사</span>';
echo '</div>';
echo '<span class="file-pin" onclick="event.stopPropagation();togglePin(this, \''.h($rel).'\', \'file\')">⭐</span>';
echo '<div class="file-size">('.number_format($it['size']).' bytes)</div>';
echo '</div>';
}
}
}
renderTree($tree,$ROOT_PATH);
?>
</div>
</div>
<div id="treeModal" class="tree-modal">
<div class="tree-modal-content">
<div class="tree-modal-header">
<div class="tree-modal-title">디렉토리 구도</div>
<button class="tree-modal-close" onclick="closeTreeModal()">✕</button>
</div>
<div class="tree-modal-body">
<pre id="treeModalContent"></pre>
</div>
</div>
</div>
<script>
let allDirsOpen = false;
let openBatch = [];
let fileTooltip = null;
// 검색 관련 변수 (이벤트 리스너에서 사용되므로 먼저 선언)
const searchInput = document.getElementById('searchInput');
const searchResult = document.getElementById('searchResult');
let searchActive = false;
// localStorage 키
const STORAGE_OPEN_DIRS = 'dirTree_openDirs';
const STORAGE_PINNED = 'dirTree_pinned';
const STORAGE_RECENT_CLICK = 'dirTree_recentClick';
const STORAGE_PREV_PATHS = 'dirTree_prevPaths';
// 현재 열린 디렉토리 개수 업데이트
function updateOpenCount() {
let count = 0;
document.querySelectorAll('.dir-children').forEach(el => {
if (el.classList.contains('open')) {
count++;
}
});
document.getElementById('openCount').textContent = '열림: ' + count;
}
// 변경 감지 표시
function markNewItems() {
try {
const prevPaths = JSON.parse(localStorage.getItem(STORAGE_PREV_PATHS) || '[]');
const currentPaths = <?=json_encode($currentPaths)?>;
currentPaths.forEach(path => {
if (!prevPaths.includes(path)) {
const dirHead = document.querySelector(`.dir-head[data-path="${path}"]`);
const file = document.querySelector(`.file[data-full-path="${path}"]`);
if (dirHead) {
dirHead.classList.add('new-item');
}
if (file) {
file.classList.add('new-item');
}
}
});
localStorage.setItem(STORAGE_PREV_PATHS, JSON.stringify(currentPaths));
} catch(e) {}
}
// ASCII 트리 모달 - 전체 디렉토리용
function showTreeModal(relPath) {
const modal = document.getElementById('treeModal');
const content = document.getElementById('treeModalContent');
const title = modal.querySelector('.tree-modal-title');
content.textContent = '로딩 중...';
modal.classList.add('show');
const url = '?tree=' + encodeURIComponent(relPath || '');
fetch(url)
.then(response => {
if (!response.ok) throw new Error('로딩 실패');
return response.text();
})
.then(text => {
content.textContent = text;
title.textContent = '디렉토리 구도: ' + (relPath || '/home/www');
})
.catch(err => {
content.textContent = '로딩 실패: ' + err.message;
});
}
// ASCII 트리 모달 - 특정 디렉토리용
function showTreeModalByPath(fullPath) {
const modal = document.getElementById('treeModal');
const content = document.getElementById('treeModalContent');
const title = modal.querySelector('.tree-modal-title');
content.textContent = '로딩 중...';
modal.classList.add('show');
if (!fullPath) {
content.textContent = '경로 오류';
return;
}
const url = '?tree=' + encodeURIComponent(fullPath);
fetch(url)
.then(response => {
if (!response.ok) throw new Error('로딩 실패');
return response.text();
})
.then(text => {
content.textContent = text;
title.textContent = '디렉토리 구도: ' + fullPath;
})
.catch(err => {
content.textContent = '로딩 실패: ' + err.message;
});
}
function closeTreeModal() {
document.getElementById('treeModal').classList.remove('show');
}
// 모든 디렉토리 닫기
function closeAllDirs() {
document.querySelectorAll('.dir-children').forEach(el => {
el.classList.remove('open');
});
document.querySelectorAll('.dir-toggle').forEach(el => {
el.textContent = '▶';
});
localStorage.removeItem(STORAGE_OPEN_DIRS);
allDirsOpen = false;
document.getElementById('toggleAllBtn').textContent = '전체 디렉토리 열기';
updateOpenCount();
}
// 상태 초기화
function resetState() {
closeAllDirs();
document.getElementById('searchInput').value = '';
updateSearch();
document.querySelectorAll('.file').forEach(f => f.classList.remove('hidden'));
localStorage.removeItem(STORAGE_PINNED);
localStorage.removeItem(STORAGE_RECENT_CLICK);
document.querySelectorAll('.file.recent-clicked').forEach(f => {
f.classList.remove('recent-clicked');
});
document.querySelectorAll('.dir-head.pinned, .file.pinned').forEach(el => {
el.classList.remove('pinned');
});
document.querySelectorAll('.dir-pin.active, .file-pin.active').forEach(el => {
el.classList.remove('active');
});
document.getElementById('pinnedSection').style.display = 'none';
updatePinnedSection();
}
// 디렉토리 접기
function collapseDir(path) {
const dirHead = document.querySelector(`.dir-head[data-path="${path}"]`);
if (!dirHead) return;
const children = dirHead.nextElementSibling;
if (children && children.classList.contains('dir-children')) {
children.classList.remove('open');
const toggle = dirHead.querySelector('.dir-toggle');
if (toggle) toggle.textContent = '▶';
// 하위 모든 디렉토리도 닫기
children.querySelectorAll('.dir-children').forEach(subChild => {
subChild.classList.remove('open');
});
children.querySelectorAll('.dir-toggle').forEach(subToggle => {
subToggle.textContent = '▶';
});
}
saveOpenState();
updateOpenCount();
}
// 공통 디렉토리 열기 함수 (개별/전체/검색 모두 사용)
function openDir(dirHead) {
const children = dirHead.nextElementSibling;
if (!children || !children.classList.contains('dir-children')) return;
// 이미 열려있으면 스킵
if (children.classList.contains('open')) {
return;
}
// 부모가 닫혀있으면 먼저 열기 (재귀적으로)
const parentNode = dirHead.closest('.node');
if (parentNode && parentNode.parentElement) {
const parentContainer = parentNode.parentElement;
if (parentContainer.classList.contains('dir-children')) {
const grandParentNode = parentContainer.parentElement;
if (grandParentNode) {
const parentHead = grandParentNode.querySelector('.dir-head');
if (parentHead && parentHead !== dirHead) {
const parentChildren = parentHead.nextElementSibling;
if (parentChildren && parentChildren.classList.contains('dir-children')) {
if (!parentChildren.classList.contains('open')) {
openDir(parentHead);
}
}
}
}
}
}
// open 클래스만 추가 (CSS가 display를 관리)
children.classList.add('open');
const toggle = dirHead.querySelector('.dir-toggle');
if (toggle) toggle.textContent = '▼';
}
// 공통 디렉토리 닫기 함수
function closeDir(dirHead) {
const children = dirHead.nextElementSibling;
if (!children || !children.classList.contains('dir-children')) return;
// open 클래스만 제거 (CSS가 display를 관리)
children.classList.remove('open');
const toggle = dirHead.querySelector('.dir-toggle');
if (toggle) toggle.textContent = '▶';
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(e){
if (e.target.tagName === 'INPUT' && e.target !== searchInput) return;
if (e.key === '/' && e.target !== searchInput) {
e.preventDefault();
searchInput.focus();
} else if (e.key === 'Escape') {
const modal = document.getElementById('treeModal');
if (modal.classList.contains('show')) {
closeTreeModal();
return;
}
searchInput.value = '';
updateSearch();
document.querySelectorAll('.file').forEach(f => f.classList.remove('hidden'));
} else if (e.key === '*') {
e.preventDefault();
openAllDirs();
} else if (e.key === 'r' || e.key === 'R') {
if (e.target.tagName !== 'INPUT') {
e.preventDefault();
resetState();
}
}
});
// 열림 상태 저장/복원
function saveOpenState() {
const openDirs = [];
document.querySelectorAll('.dir-children').forEach(el => {
if (el.classList.contains('open')) {
const dirHead = el.previousElementSibling;
if (dirHead && dirHead.classList.contains('dir-head') && dirHead.dataset.path) {
openDirs.push(dirHead.dataset.path);
}
}
});
localStorage.setItem(STORAGE_OPEN_DIRS, JSON.stringify(openDirs));
updateOpenCount();
}
function restoreOpenState() {
try {
const saved = localStorage.getItem(STORAGE_OPEN_DIRS);
if (!saved) return;
const openDirs = JSON.parse(saved);
// 깊이 순서대로 정렬 (부모를 먼저 열기 위해)
const dirHeads = [];
openDirs.forEach(path => {
const dirHead = document.querySelector(`.dir-head[data-path="${path}"]`);
if (dirHead) {
dirHeads.push(dirHead);
}
});
dirHeads.sort((a, b) => {
const depthA = parseInt(a.dataset.depth || '0');
const depthB = parseInt(b.dataset.depth || '0');
return depthA - depthB;
});
dirHeads.forEach(dirHead => {
openDir(dirHead);
});
updateOpenCount();
} catch(e) {}
}
// 핀 기능
function getPinnedItems() {
try {
const saved = localStorage.getItem(STORAGE_PINNED);
return saved ? JSON.parse(saved) : [];
} catch(e) {
return [];
}
}
function savePinnedItems(items) {
localStorage.setItem(STORAGE_PINNED, JSON.stringify(items));
}
function togglePin(el, path, type) {
const pinned = getPinnedItems();
const index = pinned.findIndex(p => p.path === path);
if (index >= 0) {
pinned.splice(index, 1);
el.classList.remove('active');
if (type === 'dir') {
el.closest('.dir-head').classList.remove('pinned');
} else {
el.closest('.file').classList.remove('pinned');
}
} else {
pinned.push({path: path, type: type});
el.classList.add('active');
if (type === 'dir') {
el.closest('.dir-head').classList.add('pinned');
} else {
el.closest('.file').classList.add('pinned');
}
}
savePinnedItems(pinned);
updatePinnedSection();
}
function updatePinnedSection() {
const pinned = getPinnedItems();
const section = document.getElementById('pinnedSection');
const items = document.getElementById('pinnedItems');
if (pinned.length === 0) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
items.innerHTML = '';
pinned.forEach(p => {
const item = document.createElement('div');
item.className = 'pinned-item';
const name = p.path.split('/').pop() || p.path;
item.innerHTML = `
<span>${p.type === 'dir' ? '📁' : '📄'}</span>
<span>${name}</span>
<span class="pinned-item-remove" onclick="removePin('${p.path}')">✕</span>
`;
item.style.cursor = 'pointer';
item.onclick = function() {
if (p.type === 'dir') {
const dirHead = document.querySelector(`.dir-head[data-path="${p.path}"]`);
if (dirHead) {
toggleDir(dirHead);
dirHead.scrollIntoView({behavior:'smooth', block:'center'});
}
} else {
const file = document.querySelector(`.file[data-path="${p.path}"]`);
if (file) {
file.scrollIntoView({behavior:'smooth', block:'center'});
}
}
};
items.appendChild(item);
});
// 핀 표시 업데이트
document.querySelectorAll('.dir-pin, .file-pin').forEach(el => {
el.classList.remove('active');
});
document.querySelectorAll('.dir-head, .file').forEach(el => {
el.classList.remove('pinned');
});
pinned.forEach(p => {
if (p.type === 'dir') {
const dirHead = document.querySelector(`.dir-head[data-path="${p.path}"]`);
if (dirHead) {
dirHead.classList.add('pinned');
const pin = dirHead.querySelector('.dir-pin');
if (pin) pin.classList.add('active');
}
} else {
const file = document.querySelector(`.file[data-path="${p.path}"]`);
if (file) {
file.classList.add('pinned');
const pin = file.querySelector('.file-pin');
if (pin) pin.classList.add('active');
}
}
});
}
function removePin(path) {
const pinned = getPinnedItems();
const filtered = pinned.filter(p => p.path !== path);
savePinnedItems(filtered);
updatePinnedSection();
}
// 디렉토리 경로 복사
function copyDirPath(path) {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(path).then(() => {
// 복사 성공 (조용히)
}).catch(() => {});
} else {
const textarea = document.createElement('textarea');
textarea.value = path;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
} catch(e) {}
document.body.removeChild(textarea);
}
}
// 파일 경로 복사
function copyPath(path) {
const fullPath = '/' + path;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(fullPath).then(() => {
// 복사 성공 (조용히)
}).catch(() => {});
} else {
const textarea = document.createElement('textarea');
textarea.value = fullPath;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
} catch(e) {}
document.body.removeChild(textarea);
}
}
// 최근 클릭 파일 표시
function markRecentClick(path) {
try {
const recent = JSON.parse(localStorage.getItem(STORAGE_RECENT_CLICK) || '[]');
const index = recent.indexOf(path);
if (index >= 0) recent.splice(index, 1);
recent.unshift(path);
if (recent.length > 5) recent.pop();
localStorage.setItem(STORAGE_RECENT_CLICK, JSON.stringify(recent));
document.querySelectorAll('.file.recent-clicked').forEach(f => {
f.classList.remove('recent-clicked');
});
recent.forEach(p => {
const file = document.querySelector(`.file[data-path="${p}"]`);
if (file) file.classList.add('recent-clicked');
});
} catch(e) {}
}
function toggleDir(h){
const c = h.nextElementSibling;
if (!c || !c.classList.contains('dir-children')) return;
// 열림 상태 체크
const isOpen = c.classList.contains('open');
if (isOpen) {
closeDir(h);
} else {
openDir(h);
}
saveOpenState();
}
function openAllDirs(forceOpen = false){
if (forceOpen || !allDirsOpen) {
allDirsOpen = true;
document.getElementById('toggleAllBtn').textContent = '전체 디렉토리 닫기';
// 모든 디렉토리 열기 (깊이 순서대로 부모부터)
const allDirHeads = document.querySelectorAll('.dir-head');
const dirHeadsArray = Array.from(allDirHeads);
// 깊이 순서대로 정렬 (부모를 먼저 열기 위해)
dirHeadsArray.sort((a, b) => {
const depthA = parseInt(a.dataset.depth || '0');
const depthB = parseInt(b.dataset.depth || '0');
return depthA - depthB;
});
// 각 디렉토리를 openDir 함수로 열기 (부모 자동 처리)
dirHeadsArray.forEach(dirHead => {
const children = dirHead.nextElementSibling;
if (children && children.classList.contains('dir-children')) {
if (!children.classList.contains('open')) {
openDir(dirHead);
}
}
});
saveOpenState();
} else {
const allDirHeads = document.querySelectorAll('.dir-head');
allDirHeads.forEach(dirHead => {
closeDir(dirHead);
});
allDirsOpen = false;
document.getElementById('toggleAllBtn').textContent = '전체 디렉토리 열기';
saveOpenState();
}
}
document.getElementById('toggleAllBtn').addEventListener('click', function(){
openAllDirs();
});
document.getElementById('closeAllBtn').addEventListener('click', function(){
closeAllDirs();
});
document.getElementById('resetStateBtn').addEventListener('click', function(){
resetState();
});
// 검색 하이라이트 함수
function highlightText(text, query) {
if (!query) return text;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<span class="file-name-highlight">$1</span>');
}
// 즉시 파일명 검색
function updateSearch() {
const q = searchInput.value.toLowerCase().trim();
searchActive = q.length > 0;
const files = document.querySelectorAll('.file');
let visibleCount = 0;
files.forEach(f => {
const name = f.dataset.name;
const nameEl = f.querySelector('.file-name');
if (q && !name.includes(q)) {
f.classList.add('hidden');
} else {
f.classList.remove('hidden');
if (!f.classList.contains('hidden')) {
visibleCount++;
if (q && nameEl) {
// 원본 HTML 저장 (처음 한 번만)
if (!nameEl.dataset.originalHtml) {
nameEl.dataset.originalHtml = nameEl.innerHTML;
}
// 파일명만 추출 (배지 제외)
let fileName = '';
let badges = '';
const children = nameEl.childNodes;
children.forEach(child => {
if (child.nodeType === 3) { // 텍스트 노드
fileName += child.textContent;
} else if (child.nodeType === 1 && child.classList.contains('file-badge')) { // 배지 요소
badges += child.outerHTML;
}
});
// 파일명만 하이라이트
const highlightedName = highlightText(fileName.trim(), q);
nameEl.innerHTML = highlightedName + badges;
}
}
}
});
if (searchActive && searchResult) {
searchResult.textContent = '결과: ' + visibleCount;
// 모든 디렉토리 열기
document.querySelectorAll('.dir-head').forEach(dirHead => {
const children = dirHead.nextElementSibling;
if (children && children.classList.contains('dir-children')) {
if (!children.classList.contains('open')) {
openDir(dirHead);
}
}
});
allDirsOpen = true;
document.getElementById('toggleAllBtn').textContent = '전체 디렉토리 닫기';
} else {
if (searchResult) {
searchResult.textContent = '';
}
// 하이라이트 제거 - 원본 HTML 복원
document.querySelectorAll('.file-name').forEach(el => {
if (el.dataset.originalHtml) {
el.innerHTML = el.dataset.originalHtml;
delete el.dataset.originalHtml;
}
});
}
}
searchInput.addEventListener('input', updateSearch);
// 파일 클릭 시 최근 클릭 표시
document.addEventListener('click', function(e){
const file = e.target.closest('.file');
if (file && file.dataset.path) {
markRecentClick(file.dataset.path);
}
});
// 파일 hover tooltip - 완전 분리
document.querySelectorAll('.file').forEach(file => {
file.addEventListener('mouseenter', function(e){
const tooltipText = this.dataset.tooltip;
if (!tooltipText) return;
if (!fileTooltip) {
fileTooltip = document.createElement('div');
fileTooltip.className = 'file-tooltip';
document.body.appendChild(fileTooltip);
}
const rect = this.getBoundingClientRect();
fileTooltip.textContent = tooltipText;
fileTooltip.style.left = rect.left + 'px';
fileTooltip.style.top = (rect.top - 10) + 'px';
fileTooltip.style.transform = 'translateY(-100%)';
fileTooltip.classList.add('show');
});
file.addEventListener('mouseleave', function(e){
if (fileTooltip) {
fileTooltip.classList.remove('show');
}
});
});
// 초기화
document.addEventListener('DOMContentLoaded', function(){
restoreOpenState();
updatePinnedSection();
markNewItems();
try {
const recent = JSON.parse(localStorage.getItem(STORAGE_RECENT_CLICK) || '[]');
recent.forEach(path => {
const file = document.querySelector(`.file[data-path="${path}"]`);
if (file) file.classList.add('recent-clicked');
});
} catch(e) {}
});
</script>
</div>
<?php require_once '/home/www/GNU/_PAGE/tail.php'; ?>